fix(core): clearer tui colors on light themes (#31095)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

Light themes are not super clear with the new TUI.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

Light themes are much clearer with the new TUI.

Internal loom shared on slack for full details.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #

---------

Co-authored-by: JamesHenry <james@henry.sc>
This commit is contained in:
Jason Jean 2025-05-07 16:29:36 -04:00 committed by GitHub
parent b65216387e
commit 0d53604b5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 338 additions and 213 deletions

View File

@ -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

View File

@ -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

33
Cargo.lock generated
View File

@ -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"

4
clippy.toml Normal file
View File

@ -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" },
]

View File

@ -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"] }

View File

@ -152,6 +152,19 @@
"args": ["--all"]
}
}
},
"lint-native": {
"command": "cargo clippy",
"cache": true,
"options": {
"cwd": "{projectRoot}/src/native",
"args": ["--frozen"]
},
"configurations": {
"fix": {
"args": []
}
}
}
}
}

View File

@ -1,3 +1,4 @@
#![deny(clippy::disallowed_types)]
#![cfg_attr(target_os = "wasi", feature(wasi_ext))]
// add all the napi macros globally
#[macro_use]

View File

@ -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<Self> {
enable_logger();
let pseudo_terminal = PseudoTerminal::default()?;
let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?;
Ok(Self { pseudo_terminal })
}

View File

@ -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<Self> {
enable_logger();
let pseudo_terminal = PseudoTerminal::default()?;
let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?;
Ok(Self { pseudo_terminal })
}

View File

@ -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<PseudoTerminal> {
Self::new(PseudoTerminalOptions::default())
}
pub fn run_command(
&mut self,
command: String,

View File

@ -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(());
}

View File

@ -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 {

View File

@ -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<Action>) -> Result<()> {
self.action_tx = Some(tx);
@ -367,12 +357,9 @@ impl Component for HelpPopup {
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Resize(w, h) => {
if let Action::Resize(w, h) = action {
self.handle_resize(w, h);
}
_ => {}
}
Ok(None)
}

View File

@ -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("<enter>", 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("<enter>", key_style),
];
f.render_widget(

View File

@ -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);
}

View File

@ -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);

View File

@ -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 {

View File

@ -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<Cell> {
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

View File

@ -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<Arc<PtyInstance>>,
@ -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 <tab> to focus ")
let tab_target_text = Span::raw("Press <tab> 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("<ctrl>+z", Style::default().fg(Color::Cyan)),
Span::styled("<ctrl>+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),
)])
};

View File

@ -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(())
}

View File

@ -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;

View File

@ -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<Theme> = 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
}
}
}

View File

@ -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)?;