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 & pnpm nx run-many -t check-imports check-commit check-lock-files check-codeowners --parallel=1 --no-dte &
pids+=($!) 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+=($!) pids+=($!)
for pid in "${pids[@]}"; do for pid in "${pids[@]}"; do

View File

@ -1,5 +1,6 @@
pnpm check-lock-files 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 check-commit
pnpm documentation pnpm documentation
pnpm pretty-quick --check
pnpm nx format-native nx

33
Cargo.lock generated
View File

@ -2202,6 +2202,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tar", "tar",
"tempfile", "tempfile",
"terminal-colorsaurus",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-util", "tokio-util",
@ -3550,6 +3551,32 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "termios" name = "termios"
version = "0.2.2" version = "0.2.2"
@ -4636,6 +4663,12 @@ dependencies = [
"rustix 1.0.5", "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]] [[package]]
name = "xxhash-rust" name = "xxhash-rust"
version = "0.8.10" 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" sysinfo = "0.33.1"
rand = "0.9.0" rand = "0.9.0"
tar = "0.4.44" tar = "0.4.44"
terminal-colorsaurus = "0.4.0"
thiserror = "1.0.40" thiserror = "1.0.40"
tracing = "0.1.37" tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }

View File

@ -152,6 +152,19 @@
"args": ["--all"] "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))] #![cfg_attr(target_os = "wasi", feature(wasi_ext))]
// add all the napi macros globally // add all the napi macros globally
#[macro_use] #[macro_use]

View File

@ -1,10 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use tracing::trace; use tracing::trace;
use watchexec::command;
use super::child_process::ChildProcess; use super::child_process::ChildProcess;
use super::os; use super::os;
use super::pseudo_terminal::PseudoTerminal; use super::pseudo_terminal::{PseudoTerminal, PseudoTerminalOptions};
use crate::native::logger::enable_logger; use crate::native::logger::enable_logger;
#[napi] #[napi]
@ -18,7 +17,7 @@ impl RustPseudoTerminal {
pub fn new() -> napi::Result<Self> { pub fn new() -> napi::Result<Self> {
enable_logger(); enable_logger();
let pseudo_terminal = PseudoTerminal::default()?; let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?;
Ok(Self { pseudo_terminal }) Ok(Self { pseudo_terminal })
} }

View File

@ -4,7 +4,7 @@ use tracing::trace;
use super::child_process::ChildProcess; use super::child_process::ChildProcess;
use super::os; use super::os;
use super::pseudo_terminal::PseudoTerminal; use super::pseudo_terminal::{PseudoTerminal, PseudoTerminalOptions};
use crate::native::logger::enable_logger; use crate::native::logger::enable_logger;
#[napi] #[napi]
@ -18,7 +18,7 @@ impl RustPseudoTerminal {
pub fn new() -> napi::Result<Self> { pub fn new() -> napi::Result<Self> {
enable_logger(); enable_logger();
let pseudo_terminal = PseudoTerminal::default()?; let pseudo_terminal = PseudoTerminal::new(PseudoTerminalOptions::default())?;
Ok(Self { pseudo_terminal }) Ok(Self { pseudo_terminal })
} }

View File

@ -130,7 +130,7 @@ impl PseudoTerminal {
let write_buf = content.as_bytes(); let write_buf = content.as_bytes();
debug!("Escaped Stdout: {:?}", write_buf.escape_ascii().to_string()); 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() { match e.kind() {
std::io::ErrorKind::Interrupted => { std::io::ErrorKind::Interrupted => {
if !logged_interrupted_error { if !logged_interrupted_error {
@ -172,10 +172,6 @@ impl PseudoTerminal {
}) })
} }
pub fn default() -> Result<PseudoTerminal> {
Self::new(PseudoTerminalOptions::default())
}
pub fn run_command( pub fn run_command(
&mut self, &mut self,
command: String, command: String,

View File

@ -5,7 +5,7 @@ use napi::bindgen_prelude::External;
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
use ratatui::layout::{Alignment, Rect, Size}; use ratatui::layout::{Alignment, Rect, Size};
use ratatui::style::Modifier; use ratatui::style::Modifier;
use ratatui::style::{Color, Style}; use ratatui::style::Style;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use std::collections::HashMap; use std::collections::HashMap;
@ -35,6 +35,7 @@ use super::components::Component;
use super::config::TuiConfig; use super::config::TuiConfig;
use super::lifecycle::RunMode; use super::lifecycle::RunMode;
use super::pty::PtyInstance; use super::pty::PtyInstance;
use super::theme::THEME;
use super::tui; use super::tui;
use super::utils::normalize_newlines; use super::utils::normalize_newlines;
@ -121,7 +122,7 @@ impl App {
layout_manager: LayoutManager::new(task_count), layout_manager: LayoutManager::new(task_count),
frame_area: None, frame_area: None,
layout_areas: 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, spacebar_mode: false,
pane_tasks: [None, None], pane_tasks: [None, None],
task_list_hidden: false, task_list_hidden: false,
@ -255,7 +256,7 @@ impl App {
let pty = self let pty = self
.pty_instances .pty_instances
.get_mut(&task_id) .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()); pty.process_output(output.as_bytes());
} }
@ -752,11 +753,11 @@ impl App {
tui.draw(|f| { tui.draw(|f| {
let area = f.area(); let area = f.area();
// Cache the frame area if it's never been set before (will be updated in subsequent resize events if necessary) // 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); self.frame_area = Some(area);
} }
// Determine the required layout areas for the tasks list and terminal panes using the LayoutManager // 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(); self.recalculate_layout_areas();
} }
@ -777,8 +778,8 @@ impl App {
" NX ", " NX ",
Style::reset() Style::reset()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.bg(Color::Red) .bg(THEME.error)
.fg(Color::Black), .fg(THEME.primary_fg),
), ),
Span::raw(" Terminal too small "), Span::raw(" Terminal too small "),
]); ]);
@ -879,9 +880,9 @@ impl App {
Block::default() Block::default()
.title(format!(" Output {} ", pane_idx + 1)) .title(format!(" Output {} ", pane_idx + 1))
.borders(Borders::ALL) .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); .alignment(Alignment::Center);
f.render_widget(placeholder, *pane_area); f.render_widget(placeholder, *pane_area);
@ -1348,7 +1349,7 @@ impl App {
/// Actually processes the resize event by updating PTY dimensions. /// Actually processes the resize event by updating PTY dimensions.
fn handle_pty_resize(&mut self) -> io::Result<()> { fn handle_pty_resize(&mut self) -> io::Result<()> {
if !self.layout_areas.is_some() { if self.layout_areas.is_none() {
return Ok(()); return Ok(());
} }

View File

@ -1,7 +1,7 @@
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Color, Modifier, Style}, style::{Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{ widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
@ -12,6 +12,8 @@ use ratatui::{
use std::any::Any; use std::any::Any;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::native::tui::theme::THEME;
use super::Component; use super::Component;
pub struct CountdownPopup { pub struct CountdownPopup {
@ -143,27 +145,27 @@ impl CountdownPopup {
let time_remaining = seconds_remaining + 1; let time_remaining = seconds_remaining + 1;
let content = vec![ let content = [
Line::from(vec![ Line::from(vec![
Span::styled("• Press ", Style::default().fg(Color::DarkGray)), Span::styled("• Press ", Style::default().fg(THEME.secondary_fg)),
Span::styled("q to exit immediately ", Style::default().fg(Color::Cyan)), Span::styled("q to exit immediately ", Style::default().fg(THEME.info)),
Span::styled("or ", Style::default().fg(Color::DarkGray)), Span::styled("or ", Style::default().fg(THEME.secondary_fg)),
Span::styled("any other key ", Style::default().fg(Color::Cyan)), Span::styled("any other key ", Style::default().fg(THEME.info)),
Span::styled( Span::styled(
"to keep the TUI running and interactively explore the results.", "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(""),
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
"• Learn how to configure auto-exit and more in the docs: ", "• Learn how to configure auto-exit and more in the docs: ",
Style::default().fg(Color::DarkGray), Style::default().fg(THEME.secondary_fg),
), ),
Span::styled( Span::styled(
// NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 // 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", "https://nx.dev/terminal-ui",
Style::default().fg(Color::Cyan), Style::default().fg(THEME.info),
), ),
]), ]),
]; ];
@ -175,20 +177,20 @@ impl CountdownPopup {
" NX ", " NX ",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.bg(Color::Cyan) .bg(THEME.info)
.fg(Color::Black), .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( Span::styled(
format!("{}", time_remaining), 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) .title_alignment(Alignment::Left)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Plain) .border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Cyan)) .border_style(Style::default().fg(THEME.info))
.padding(Padding::proportional(1)); .padding(Padding::proportional(1));
// Get the inner area // Get the inner area
@ -264,14 +266,14 @@ impl CountdownPopup {
f.render_widget( f.render_widget(
Paragraph::new(top_text) Paragraph::new(top_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(THEME.info)),
top_right_area, top_right_area,
); );
f.render_widget( f.render_widget(
Paragraph::new(bottom_text) Paragraph::new(bottom_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(THEME.info)),
bottom_right_area, bottom_right_area,
); );
@ -279,27 +281,13 @@ impl CountdownPopup {
.orientation(ScrollbarOrientation::VerticalRight) .orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("")) .begin_symbol(Some(""))
.end_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); 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 { impl Component for CountdownPopup {
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
if self.visible { if self.visible {

View File

@ -1,7 +1,7 @@
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{ widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
@ -11,9 +11,9 @@ use ratatui::{
use std::any::Any; use std::any::Any;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::native::tui::action::Action;
use super::{Component, Frame}; use super::{Component, Frame};
use crate::native::tui::action::Action;
use crate::native::tui::theme::THEME;
pub struct HelpPopup { pub struct HelpPopup {
scroll_offset: usize, scroll_offset: usize,
@ -154,29 +154,29 @@ impl HelpPopup {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
"Thanks for using Nx! To get the most out of this terminal UI, please check out the docs: ", "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( Span::styled(
// NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 // 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", "https://nx.dev/terminal-ui",
Style::default().fg(Color::Cyan), Style::default().fg(THEME.info),
), ),
]), ]),
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
"If you are finding Nx useful, please consider giving it a star on GitHub, it means a lot: ", "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( Span::styled(
// NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 // 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", "https://github.com/nrwl/nx",
Style::default().fg(Color::Cyan), Style::default().fg(THEME.info),
), ),
]), ]),
Line::from(""), // Empty line for spacing Line::from(""), // Empty line for spacing
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"Available keyboard shortcuts:", "Available keyboard shortcuts:",
Style::default().fg(Color::DarkGray), Style::default().fg(THEME.secondary_fg),
)]), )]),
Line::from(""), // Empty line for spacing Line::from(""), // Empty line for spacing
]; ];
@ -201,12 +201,12 @@ impl HelpPopup {
if i > 0 { if i > 0 {
spans.push(Span::styled( spans.push(Span::styled(
" or ", " or ",
Style::default().fg(Color::DarkGray), Style::default().fg(THEME.secondary_fg),
)); ));
} }
spans.push(Span::styled( spans.push(Span::styled(
part.to_string(), part.to_string(),
Style::default().fg(Color::Cyan), Style::default().fg(THEME.info),
)); ));
} }
@ -215,8 +215,11 @@ impl HelpPopup {
spans.push(Span::raw(padding)); spans.push(Span::raw(padding));
// Add the separator and description // Add the separator and description
spans.push(Span::styled("= ", Style::default().fg(Color::DarkGray))); spans.push(Span::styled(
spans.push(Span::styled(desc, Style::default().fg(Color::White))); "= ",
Style::default().fg(THEME.secondary_fg),
));
spans.push(Span::styled(desc, Style::default().fg(THEME.primary_fg)));
Line::from(spans) Line::from(spans)
} }
@ -232,15 +235,15 @@ impl HelpPopup {
" NX ", " NX ",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.bg(Color::Cyan) .bg(THEME.info)
.fg(Color::Black), .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) .title_alignment(Alignment::Left)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Plain) .border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Cyan)) .border_style(Style::default().fg(THEME.info))
.padding(Padding::proportional(1)); .padding(Padding::proportional(1));
let inner_area = block.inner(popup_area); let inner_area = block.inner(popup_area);
@ -318,14 +321,14 @@ impl HelpPopup {
f.render_widget( f.render_widget(
Paragraph::new(top_text) Paragraph::new(top_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(THEME.info)),
top_right_area, top_right_area,
); );
f.render_widget( f.render_widget(
Paragraph::new(bottom_text) Paragraph::new(bottom_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(THEME.info)),
bottom_right_area, bottom_right_area,
); );
@ -333,26 +336,13 @@ impl HelpPopup {
.orientation(ScrollbarOrientation::VerticalRight) .orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("")) .begin_symbol(Some(""))
.end_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); 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 { impl Component for HelpPopup {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.action_tx = Some(tx); self.action_tx = Some(tx);
@ -367,12 +357,9 @@ impl Component for HelpPopup {
} }
fn update(&mut self, action: Action) -> Result<Option<Action>> { fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action { if let Action::Resize(w, h) = action {
Action::Resize(w, h) => {
self.handle_resize(w, h); self.handle_resize(w, h);
} }
_ => {}
}
Ok(None) Ok(None)
} }

View File

@ -1,11 +1,13 @@
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Color, Modifier, Style}, style::{Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame, Frame,
}; };
use crate::native::tui::theme::THEME;
pub struct HelpText { pub struct HelpText {
collapsed_mode: bool, collapsed_mode: bool,
is_dimmed: bool, is_dimmed: bool,
@ -48,15 +50,17 @@ impl HelpText {
} else { } else {
Style::default() Style::default()
}; };
let key_style = base_style.fg(THEME.info);
let label_style = base_style.fg(THEME.secondary_fg);
if self.collapsed_mode { if self.collapsed_mode {
// Show minimal hint // Show minimal hint
let hint = vec![ let hint = vec![
Span::styled("quit: ", base_style.fg(Color::DarkGray)), Span::styled("quit: ", label_style),
Span::styled("q", base_style.fg(Color::Cyan)), Span::styled("q", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("help: ", label_style),
Span::styled("?", base_style.fg(Color::Cyan)), Span::styled("?", key_style),
]; ];
f.render_widget( f.render_widget(
Paragraph::new(Line::from(hint)).alignment(if self.align_left { Paragraph::new(Line::from(hint)).alignment(if self.align_left {
@ -69,26 +73,26 @@ impl HelpText {
} else { } else {
// Show full shortcuts // Show full shortcuts
let shortcuts = vec![ let shortcuts = vec![
Span::styled("quit: ", base_style.fg(Color::DarkGray)), Span::styled("quit: ", label_style),
Span::styled("q", base_style.fg(Color::Cyan)), Span::styled("q", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("help: ", label_style),
Span::styled("?", base_style.fg(Color::Cyan)), Span::styled("?", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("navigate: ", base_style.fg(Color::DarkGray)), Span::styled("navigate: ", label_style),
Span::styled("↑ ↓", base_style.fg(Color::Cyan)), Span::styled("↑ ↓", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("filter: ", base_style.fg(Color::DarkGray)), Span::styled("filter: ", label_style),
Span::styled("/", base_style.fg(Color::Cyan)), Span::styled("/", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("pin output: ", base_style.fg(Color::DarkGray)), Span::styled("pin output: ", label_style),
Span::styled("", base_style.fg(Color::DarkGray)), Span::styled("", label_style),
Span::styled("1", base_style.fg(Color::Cyan)), Span::styled("1", key_style),
Span::styled(" or ", base_style.fg(Color::DarkGray)), Span::styled(" or ", label_style),
Span::styled("2", base_style.fg(Color::Cyan)), Span::styled("2", key_style),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", label_style),
Span::styled("show output: ", base_style.fg(Color::DarkGray)), Span::styled("show output: ", label_style),
Span::styled("<enter>", base_style.fg(Color::Cyan)), Span::styled("<enter>", key_style),
]; ];
f.render_widget( f.render_widget(

View File

@ -739,7 +739,6 @@ mod tests {
#[cfg(test)] #[cfg(test)]
mod visual_tests { mod visual_tests {
use super::*; use super::*;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders}; use ratatui::widgets::{Block, Borders};
use ratatui::{backend::TestBackend, Terminal}; use ratatui::{backend::TestBackend, Terminal};
@ -758,10 +757,8 @@ mod tests {
// Render task list if visible // Render task list if visible
if let Some(task_list_area) = areas.task_list { if let Some(task_list_area) = areas.task_list {
let task_list_block = Block::default() let task_list_block =
.title("Task List") Block::default().title("Task List").borders(Borders::ALL);
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
frame.render_widget(task_list_block, task_list_area); 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() { for (i, pane_area) in areas.terminal_panes.iter().enumerate() {
let pane_block = Block::default() let pane_block = Block::default()
.title(format!("Terminal Pane {}", i + 1)) .title(format!("Terminal Pane {}", i + 1))
.borders(Borders::ALL) .borders(Borders::ALL);
.border_style(Style::default().fg(Color::Yellow));
frame.render_widget(pane_block, *pane_area); frame.render_widget(pane_block, *pane_area);
} }

View File

@ -1,6 +1,7 @@
use crate::native::tui::theme::THEME;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame, Frame,
@ -52,9 +53,9 @@ impl Pagination {
// Left arrow - dim if we're on the first page // Left arrow - dim if we're on the first page
let left_arrow = if current_page == 0 { 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 { } else {
Span::styled("", base_style.fg(Color::Cyan)) Span::styled("", base_style.fg(THEME.info))
}; };
spans.push(left_arrow); spans.push(left_arrow);
@ -62,15 +63,15 @@ impl Pagination {
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
spans.push(Span::styled( spans.push(Span::styled(
format!("{}/{}", current_page + 1, total_pages), format!("{}/{}", current_page + 1, total_pages),
base_style.fg(Color::DarkGray), base_style.fg(THEME.secondary_fg),
)); ));
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
// Right arrow - dim if we're on the last page // Right arrow - dim if we're on the last page
let right_arrow = if current_page >= total_pages.saturating_sub(1) { 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 { } else {
Span::styled("", base_style.fg(Color::Cyan)) Span::styled("", base_style.fg(THEME.info))
}; };
spans.push(right_arrow); spans.push(right_arrow);

View File

@ -214,7 +214,7 @@ impl TaskSelectionManager {
pub fn is_selected(&self, task_name: &str) -> bool { pub fn is_selected(&self, task_name: &str) -> bool {
self.selected_task_name self.selected_task_name
.as_ref() .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> { pub fn get_selected_task_name(&self) -> Option<&String> {
@ -222,7 +222,7 @@ impl TaskSelectionManager {
} }
pub fn total_pages(&self) -> usize { 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 { pub fn get_current_page(&self) -> usize {

View File

@ -2,7 +2,7 @@ use color_eyre::eyre::Result;
use hashbrown::HashSet; use hashbrown::HashSet;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize}, style::{Modifier, Style, Stylize},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Cell, Paragraph, Row, Table}, widgets::{Block, Cell, Paragraph, Row, Table},
Frame, Frame,
@ -13,6 +13,10 @@ use std::{
}; };
use tokio::sync::mpsc::UnboundedSender; 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::{ use crate::native::{
tasks::types::{Task, TaskResult}, tasks::types::{Task, TaskResult},
tui::{ 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_KEPT_EXISTING: &str = "Kept Existing";
const CACHE_STATUS_LOCAL: &str = "Local"; const CACHE_STATUS_LOCAL: &str = "Local";
const CACHE_STATUS_REMOTE: &str = "Remote"; const CACHE_STATUS_REMOTE: &str = "Remote";
@ -65,7 +65,7 @@ impl Clone for TaskItem {
name: self.name.clone(), name: self.name.clone(),
duration: self.duration.clone(), duration: self.duration.clone(),
cache_status: self.cache_status.clone(), cache_status: self.cache_status.clone(),
status: self.status.clone(), status: self.status,
continuous: self.continuous, continuous: self.continuous,
terminal_output: self.terminal_output.clone(), terminal_output: self.terminal_output.clone(),
start_time: self.start_time, start_time: self.start_time,
@ -313,7 +313,10 @@ impl TasksList {
if in_progress_count < self.max_parallel { if in_progress_count < self.max_parallel {
// When we have fewer InProgress tasks than self.max_parallel, fill the remaining slots // When we have fewer InProgress tasks than self.max_parallel, fill the remaining slots
// with empty placeholder rows to maintain the fixed height // 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 // 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. /// Returns a dimmed style when focus is not on the task list.
fn get_table_style(&self) -> Style { fn get_table_style(&self) -> Style {
if self.is_task_list_focused() { if self.is_task_list_focused() {
Style::default() Style::default().fg(THEME.secondary_fg)
} else { } 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. /// Shows either filter input or task status based on current state.
fn get_header_cells(&self, has_narrow_area_width: bool) -> Vec<Cell> { fn get_header_cells(&self, has_narrow_area_width: bool) -> Vec<Cell> {
let status_style = if !self.is_task_list_focused() { let status_style = if !self.is_task_list_focused() {
Style::default().fg(Color::DarkGray).dim() Style::default().fg(THEME.secondary_fg).dim()
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(THEME.secondary_fg)
}; };
// Determine if all tasks are completed and the status color to use // Determine if all tasks are completed and the status color to use
@ -579,12 +582,12 @@ impl TasksList {
.iter() .iter()
.any(|t| matches!(t.status, TaskStatus::Failure)); .any(|t| matches!(t.status, TaskStatus::Failure));
if has_failures { if has_failures {
Color::Red THEME.error
} else { } else {
Color::Green THEME.success
} }
} else { } else {
Color::Cyan THEME.info
}; };
// Leave first cell empty for the logo // Leave first cell empty for the logo
@ -685,9 +688,9 @@ impl TasksList {
let should_dim = !self.is_task_list_focused(); let should_dim = !self.is_task_list_focused();
let filter_style = if should_dim { let filter_style = if should_dim {
Style::default().fg(Color::Yellow).dim() Style::default().fg(THEME.warning).dim()
} else { } else {
Style::default().fg(Color::Yellow) Style::default().fg(THEME.warning)
}; };
let instruction_text = if hidden_tasks > 0 { let instruction_text = if hidden_tasks > 0 {
@ -725,7 +728,7 @@ impl TasksList {
.unwrap() .unwrap()
.get_current_page_entries(); .get_current_page_entries();
let selected_style = Style::default() let selected_style = Style::default()
.fg(Color::White) .fg(THEME.primary_fg)
.add_modifier(Modifier::BOLD); .add_modifier(Modifier::BOLD);
let normal_style = Style::default(); let normal_style = Style::default();
@ -747,7 +750,7 @@ impl TasksList {
// Determine the color of the NX logo based on task status // Determine the color of the NX logo based on task status
let logo_color = if self.tasks.is_empty() { let logo_color = if self.tasks.is_empty() {
// No tasks // No tasks
Color::Cyan THEME.info
} else if all_tasks_completed { } else if all_tasks_completed {
// All tasks are completed, check if any failed // All tasks are completed, check if any failed
let has_failures = self let has_failures = self
@ -755,13 +758,13 @@ impl TasksList {
.iter() .iter()
.any(|t| matches!(t.status, TaskStatus::Failure)); .any(|t| matches!(t.status, TaskStatus::Failure));
if has_failures { if has_failures {
Color::Red THEME.error
} else { } else {
Color::Green THEME.success
} }
} else { } else {
// Tasks are still running // Tasks are still running
Color::Cyan THEME.info
}; };
// Get header cells using the existing method but add NX logo to first cell // 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 // Use the logo color for the title text as well
logo_color logo_color
} else { } else {
Color::White THEME.primary_fg
}; };
// Apply modifiers based on focus state // Apply modifiers based on focus state
@ -803,7 +806,7 @@ impl TasksList {
// First cell: Just the NX logo and box corner if needed // First cell: Just the NX logo and box corner if needed
let mut first_cell_spans = vec![Span::styled( let mut first_cell_spans = vec![Span::styled(
" NX ", " 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 // Add box corner if needed
@ -872,7 +875,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add vertical line for visual continuity, only on first page // Add vertical line for visual continuity, only on first page
if is_first_page && self.max_parallel > 0 { if is_first_page && self.max_parallel > 0 {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -888,7 +891,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add vertical line for visual continuity, only on first page // Add vertical line for visual continuity, only on first page
if is_first_page && self.max_parallel > 0 { if is_first_page && self.max_parallel > 0 {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -918,7 +921,7 @@ impl TasksList {
.selection_manager .selection_manager
.lock() .lock()
.unwrap() .unwrap()
.is_selected(&task_name); .is_selected(task_name);
// Use the helper method to check if we should show the parallel section // Use the helper method to check if we should show the parallel section
let show_parallel = self.should_show_parallel_section(); let show_parallel = self.should_show_parallel_section();
@ -933,19 +936,19 @@ impl TasksList {
| TaskStatus::RemoteCache => Cell::from(Line::from(vec![ | TaskStatus::RemoteCache => Cell::from(Line::from(vec![
Span::raw(if is_selected { ">" } else { " " }), Span::raw(if is_selected { ">" } else { " " }),
Span::raw(" "), Span::raw(" "),
Span::styled("", Style::default().fg(Color::Green)), Span::styled("", Style::default().fg(THEME.success)),
Span::raw(" "), Span::raw(" "),
])), ])),
TaskStatus::Failure => Cell::from(Line::from(vec![ TaskStatus::Failure => Cell::from(Line::from(vec![
Span::raw(if is_selected { ">" } else { " " }), Span::raw(if is_selected { ">" } else { " " }),
Span::raw(" "), Span::raw(" "),
Span::styled("", Style::default().fg(Color::Red)), Span::styled("", Style::default().fg(THEME.error)),
Span::raw(" "), Span::raw(" "),
])), ])),
TaskStatus::Skipped => Cell::from(Line::from(vec![ TaskStatus::Skipped => Cell::from(Line::from(vec![
Span::raw(if is_selected { ">" } else { " " }), Span::raw(if is_selected { ">" } else { " " }),
Span::raw(" "), Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)), Span::styled("", Style::default().fg(THEME.warning)),
Span::raw(" "), Span::raw(" "),
])), ])),
TaskStatus::InProgress | TaskStatus::Shared => { TaskStatus::InProgress | TaskStatus::Shared => {
@ -959,7 +962,7 @@ impl TasksList {
if is_in_parallel_section if is_in_parallel_section
&& self.selection_manager.lock().unwrap().get_current_page() == 0 && 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 { } else {
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
} }
@ -967,7 +970,7 @@ impl TasksList {
// Add the spinner with consistent spacing // Add the spinner with consistent spacing
spans.push(Span::styled( spans.push(Span::styled(
throbber_char.to_string(), throbber_char.to_string(),
Style::default().fg(Color::LightCyan), Style::default().fg(THEME.info_light),
)); ));
// Add trailing space to maintain consistent width // Add trailing space to maintain consistent width
@ -978,14 +981,14 @@ impl TasksList {
TaskStatus::Stopped => Cell::from(Line::from(vec![ TaskStatus::Stopped => Cell::from(Line::from(vec![
Span::raw(if is_selected { ">" } else { " " }), Span::raw(if is_selected { ">" } else { " " }),
Span::raw(" "), Span::raw(" "),
Span::styled("", Style::default().fg(Color::DarkGray)), Span::styled("", Style::default().fg(THEME.secondary_fg)),
Span::raw(" "), Span::raw(" "),
])), ])),
TaskStatus::NotStarted => Cell::from(Line::from(vec![ TaskStatus::NotStarted => Cell::from(Line::from(vec![
Span::raw(if is_selected { ">" } else { " " }), Span::raw(if is_selected { ">" } else { " " }),
// No need for parallel section check for pending tasks // No need for parallel section check for pending tasks
Span::raw(" "), Span::raw(" "),
Span::styled("·", Style::default().fg(Color::DarkGray)), Span::styled("·", Style::default().fg(THEME.secondary_fg)),
Span::raw(" "), Span::raw(" "),
])), ])),
}; };
@ -1136,7 +1139,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add space and vertical line for parallel section (fixed position) // Add space and vertical line for parallel section (fixed position)
if is_first_page && self.max_parallel > 0 { if is_first_page && self.max_parallel > 0 {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -1152,7 +1155,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add space and vertical line for parallel section (fixed position) // Add space and vertical line for parallel section (fixed position)
if is_first_page && self.max_parallel > 0 { if is_first_page && self.max_parallel > 0 {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -1176,7 +1179,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add bottom corner for the box, or just spaces if not on first page // Add bottom corner for the box, or just spaces if not on first page
if is_first_page { if is_first_page {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -1192,7 +1195,7 @@ impl TasksList {
Span::raw(" "), Span::raw(" "),
// Add bottom corner for the box, or just spaces if not on first page // Add bottom corner for the box, or just spaces if not on first page
if is_first_page { if is_first_page {
Span::styled("", Style::default().fg(Color::Cyan)) Span::styled("", Style::default().fg(THEME.info))
} else { } else {
Span::raw(" ") Span::raw(" ")
}, },
@ -1292,9 +1295,9 @@ impl TasksList {
} }
let message_style = if is_dimmed { let message_style = if is_dimmed {
Style::default().fg(Color::DarkGray).dim() Style::default().fg(THEME.secondary_fg).dim()
} else { } 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 // 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 mut spans = vec![];
let url_style = if is_dimmed { let url_style = if is_dimmed {
Style::default().fg(Color::LightCyan).underlined().dim() Style::default().fg(THEME.info).underlined().dim()
} else { } else {
Style::default().fg(Color::LightCyan).underlined() Style::default().fg(THEME.info).underlined()
}; };
// Determine what fits, prioritizing the URL // Determine what fits, prioritizing the URL
@ -1558,7 +1561,7 @@ impl Component for TasksList {
.split(paghelp_or_help_vertical_area); .split(paghelp_or_help_vertical_area);
// Render components with safety checks // Render components with safety checks
if row_chunks.len() > 0 if !row_chunks.is_empty()
&& row_chunks[0].height > 0 && row_chunks[0].height > 0
&& row_chunks[0].width > 0 && row_chunks[0].width > 0
&& row_chunks[0].y < f.area().height && row_chunks[0].y < f.area().height
@ -1658,7 +1661,7 @@ impl Component for TasksList {
.split(paghelp_or_help_vertical_area); .split(paghelp_or_help_vertical_area);
// Render components with safety checks // Render components with safety checks
if row_chunks.len() > 0 if !row_chunks.is_empty()
&& row_chunks[0].height > 0 && row_chunks[0].height > 0
&& row_chunks[0].width > 0 && row_chunks[0].width > 0
&& row_chunks[0].y < f.area().height && row_chunks[0].y < f.area().height
@ -1723,7 +1726,7 @@ impl Component for TasksList {
.split(paghelp_or_help_vertical_area); .split(paghelp_or_help_vertical_area);
// Render components with safety checks // Render components with safety checks
if row_chunks.len() > 0 if !row_chunks.is_empty()
&& row_chunks[0].height > 0 && row_chunks[0].height > 0
&& row_chunks[0].width > 0 && row_chunks[0].width > 0
&& row_chunks[0].y < f.area().height && row_chunks[0].y < f.area().height

View File

@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Color, Modifier, Style}, style::{Modifier, Style, Stylize},
text::{Line, Span}, text::{Line, Span},
widgets::{ widgets::{
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
@ -13,9 +13,9 @@ use ratatui::{
use std::{io, sync::Arc}; use std::{io, sync::Arc};
use tui_term::widget::PseudoTerminal; use tui_term::widget::PseudoTerminal;
use crate::native::tui::pty::PtyInstance;
use super::tasks_list::TaskStatus; use super::tasks_list::TaskStatus;
use crate::native::tui::pty::PtyInstance;
use crate::native::tui::theme::THEME;
pub struct TerminalPaneData { pub struct TerminalPaneData {
pub pty: Option<Arc<PtyInstance>>, pub pty: Option<Arc<PtyInstance>>,
@ -199,35 +199,35 @@ impl<'a> TerminalPane<'a> {
| TaskStatus::RemoteCache => Span::styled( | TaskStatus::RemoteCache => Span::styled(
"", "",
Style::default() Style::default()
.fg(Color::Green) .fg(THEME.success)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
TaskStatus::Failure => Span::styled( 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( TaskStatus::Skipped => Span::styled(
"", "",
Style::default() Style::default()
.fg(Color::Yellow) .fg(THEME.warning)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
TaskStatus::InProgress | TaskStatus::Shared => Span::styled( TaskStatus::InProgress | TaskStatus::Shared => Span::styled(
"", "",
Style::default() Style::default().fg(THEME.info).add_modifier(Modifier::BOLD),
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
), ),
TaskStatus::Stopped => Span::styled( TaskStatus::Stopped => Span::styled(
"", "",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(THEME.secondary_fg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
TaskStatus::NotStarted => Span::styled( TaskStatus::NotStarted => Span::styled(
" · ", " · ",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(THEME.secondary_fg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
} }
@ -238,11 +238,11 @@ impl<'a> TerminalPane<'a> {
TaskStatus::Success TaskStatus::Success
| TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCacheKeptExisting
| TaskStatus::LocalCache | TaskStatus::LocalCache
| TaskStatus::RemoteCache => Color::Green, | TaskStatus::RemoteCache => THEME.success,
TaskStatus::Failure => Color::Red, TaskStatus::Failure => THEME.error,
TaskStatus::Skipped => Color::Yellow, TaskStatus::Skipped => THEME.warning,
TaskStatus::InProgress | TaskStatus::Shared => Color::LightCyan, TaskStatus::InProgress | TaskStatus::Shared => THEME.info,
TaskStatus::NotStarted | TaskStatus::Stopped => Color::DarkGray, 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> { impl<'a> StatefulWidget for TerminalPane<'a> {
type State = TerminalPaneState; type State = TerminalPaneState;
@ -294,7 +296,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
// Only attempt to render if we have a valid area // Only attempt to render if we have a valid area
let text = "..."; let text = "...";
let paragraph = Paragraph::new(text) let paragraph = Paragraph::new(text)
.style(Style::default().fg(Color::Gray)) .style(Style::default().fg(THEME.secondary_fg))
.alignment(Alignment::Center); .alignment(Alignment::Center);
Widget::render(paragraph, safe_area, buf); 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 status_icon = self.get_status_icon(state.task_status);
let block = Block::default() let block = Block::default()
.title(Line::from(if state.is_focused { .title(Line::from(if state.is_focused {
vec![ vec![
status_icon.clone(), status_icon.clone(),
Span::raw(format!("{} ", state.task_name)) Span::raw(format!("{} ", state.task_name))
.style(Style::default().fg(Color::White)), .style(Style::default().fg(THEME.primary_fg)),
] ]
} else { } else {
vec![ vec![
status_icon.clone(), status_icon.clone(),
Span::raw(format!("{} ", state.task_name)) 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 { 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 { } else {
Span::raw("") Span::raw("")
}, },
@ -338,15 +348,26 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
})) }))
.title_alignment(Alignment::Left) .title_alignment(Alignment::Left)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Plain) .border_type(if state.is_focused {
BorderType::Thick
} else {
BorderType::Plain
})
.border_style(border_style) .border_style(border_style)
.padding(Padding::new(2, 2, 1, 1)); .padding(Padding::new(2, 2, 1, 1));
// If task hasn't started yet, show pending message // If task hasn't started yet, show pending message
if matches!(state.task_status, TaskStatus::NotStarted) { 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( let message = vec![Line::from(vec![Span::styled(
"Task is pending...", "Task is pending...",
Style::default().fg(Color::DarkGray), message_style,
)])]; )])];
let paragraph = Paragraph::new(message) let paragraph = Paragraph::new(message)
@ -470,19 +491,19 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
let bottom_text = if self.is_currently_interactive() { let bottom_text = if self.is_currently_interactive() {
Line::from(vec![ Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled("<ctrl>+z", Style::default().fg(Color::Cyan)), Span::styled("<ctrl>+z", Style::default().fg(THEME.info)),
Span::styled( Span::styled(
" to exit interactive ", " to exit interactive ",
Style::default().fg(Color::White), Style::default().fg(THEME.primary_fg),
), ),
]) ])
} else { } else {
Line::from(vec![ Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled("i", Style::default().fg(Color::Cyan)), Span::styled("i", Style::default().fg(THEME.info)),
Span::styled( Span::styled(
" to make interactive ", " 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() { let top_text = if self.is_currently_interactive() {
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
" INTERACTIVE ", " INTERACTIVE ",
Style::default().fg(Color::White), Style::default().fg(THEME.primary_fg),
)]) )])
} else { } else {
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
" NON-INTERACTIVE ", " 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 std::sync::{Arc, Mutex};
use tracing::debug; 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::app::App;
use super::components::tasks_list::TaskStatus; use super::components::tasks_list::TaskStatus;
use super::config::{AutoExit, TuiCliArgs as RustTuiCliArgs, TuiConfig as RustTuiConfig}; use super::config::{AutoExit, TuiCliArgs as RustTuiCliArgs, TuiConfig as RustTuiConfig};
use super::tui::Tui; 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)] #[napi(object)]
#[derive(Clone)] #[derive(Clone)]
@ -49,7 +48,7 @@ impl From<(TuiConfig, &RustTuiCliArgs)> for RustTuiConfig {
Either::B(int_value) => AutoExit::Integer(int_value), Either::B(int_value) => AutoExit::Integer(int_value),
}); });
// Pass the converted JSON config value(s) and cli_args to instantiate the config with // 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 { Self {
app: Arc::new(std::sync::Mutex::new( app: Arc::new(std::sync::Mutex::new(
App::new( App::new(
tasks.into_iter().map(|t| t.into()).collect(), tasks.into_iter().collect(),
initiating_tasks, initiating_tasks,
run_mode, run_mode,
pinned_tasks, pinned_tasks,
@ -162,8 +161,8 @@ impl AppLifeCycle {
&self, &self,
done_callback: ThreadsafeFunction<(), ErrorStrategy::Fatal>, done_callback: ThreadsafeFunction<(), ErrorStrategy::Fatal>,
) -> napi::Result<()> { ) -> napi::Result<()> {
debug!("Initializing Terminal UI");
enable_logger(); enable_logger();
debug!("Initializing Terminal UI");
let app_mutex = self.app.clone(); let app_mutex = self.app.clone();
@ -287,7 +286,7 @@ impl AppLifeCycle {
#[napi(js_name = "__setCloudMessage")] #[napi(js_name = "__setCloudMessage")]
pub async fn __set_cloud_message(&self, message: String) -> napi::Result<()> { pub async fn __set_cloud_message(&self, message: String) -> napi::Result<()> {
if let Ok(mut app) = self.app.lock() { if let Ok(mut app) = self.app.lock() {
let _ = app.set_cloud_message(Some(message)); app.set_cloud_message(Some(message));
} }
Ok(()) Ok(())
} }

View File

@ -4,5 +4,7 @@ pub mod components;
pub mod config; pub mod config;
pub mod lifecycle; pub mod lifecycle;
pub mod pty; pub mod pty;
pub mod theme;
#[allow(clippy::module_inception)]
pub mod tui; pub mod tui;
pub mod utils; 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 color_eyre::eyre::Result;
use crossterm::{ use crossterm::{
cursor, cursor,
@ -165,6 +166,8 @@ impl Tui {
} }
pub fn enter(&mut self) -> Result<()> { 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"); debug!("Enabling Raw Mode");
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;