chore(core): add tui layout manager (#30947)

This commit is contained in:
James Henry 2025-05-01 01:43:11 +04:00 committed by GitHub
parent d6ea3ab45f
commit 0f4c085297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 3819 additions and 2113 deletions

50
Cargo.lock generated
View File

@ -1608,6 +1608,19 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "insta"
version = "1.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084"
dependencies = [
"console",
"linked-hash-map",
"once_cell",
"pin-project",
"similar",
]
[[package]] [[package]]
name = "instability" name = "instability"
version = "0.3.7" version = "0.3.7"
@ -1747,6 +1760,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.14" version = "0.4.14"
@ -2144,6 +2163,7 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
"ignore", "ignore",
"ignore-files 2.1.0", "ignore-files 2.1.0",
"insta",
"itertools 0.10.5", "itertools 0.10.5",
"machine-uid", "machine-uid",
"mio 0.8.11", "mio 0.8.11",
@ -2268,9 +2288,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.19.0" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "overload" name = "overload"
@ -2338,6 +2358,26 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.13" version = "0.2.13"
@ -3130,6 +3170,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"

View File

@ -87,6 +87,7 @@ napi-build = '2.1.3'
[dev-dependencies] [dev-dependencies]
assert_fs = "1.0.10" assert_fs = "1.0.10"
insta = "1.42.2"
# This is only used for unit tests # This is only used for unit tests
swc_ecma_dep_graph = "0.109.1" swc_ecma_dep_graph = "0.109.1"
tempfile = "3.13.0" tempfile = "3.13.0"

View File

@ -7,4 +7,6 @@ export default {
globals: {}, globals: {},
displayName: 'nx', displayName: 'nx',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
// Ensure cargo insta snapshots do not get picked up by jest
testPathIgnorePatterns: ['<rootDir>/src/native/tui'],
}; };

View File

@ -8,7 +8,7 @@ export declare class ExternalObject<T> {
} }
} }
export declare class AppLifeCycle { export declare class AppLifeCycle {
constructor(tasks: Array<Task>, pinnedTasks: Array<string>, tuiCliArgs: TuiCliArgs, tuiConfig: TuiConfig, titleText: string) constructor(tasks: Array<Task>, initiatingTasks: Array<string>, runMode: RunMode, pinnedTasks: Array<string>, tuiCliArgs: TuiCliArgs, tuiConfig: TuiConfig, titleText: string)
startCommand(threadCount?: number | undefined | null): void startCommand(threadCount?: number | undefined | null): void
scheduleTask(task: Task): void scheduleTask(task: Task): void
startTasks(tasks: Array<Task>, metadata: object): void startTasks(tasks: Array<Task>, metadata: object): void
@ -273,6 +273,11 @@ export declare export declare function remove(src: string): void
export declare export declare function restoreTerminal(): void export declare export declare function restoreTerminal(): void
export declare const enum RunMode {
RunOne = 0,
RunMany = 1
}
export interface RuntimeInput { export interface RuntimeInput {
runtime: string runtime: string
} }

View File

@ -391,6 +391,7 @@ module.exports.IS_WASM = nativeBinding.IS_WASM
module.exports.parseTaskStatus = nativeBinding.parseTaskStatus module.exports.parseTaskStatus = nativeBinding.parseTaskStatus
module.exports.remove = nativeBinding.remove module.exports.remove = nativeBinding.remove
module.exports.restoreTerminal = nativeBinding.restoreTerminal module.exports.restoreTerminal = nativeBinding.restoreTerminal
module.exports.RunMode = nativeBinding.RunMode
module.exports.TaskStatus = nativeBinding.TaskStatus module.exports.TaskStatus = nativeBinding.TaskStatus
module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap
module.exports.transferProjectGraph = nativeBinding.transferProjectGraph module.exports.transferProjectGraph = nativeBinding.transferProjectGraph

View File

@ -7,19 +7,19 @@ use napi::{
}; };
#[napi(object)] #[napi(object)]
#[derive(Default, Clone)] #[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct Task { pub struct Task {
pub id: String, pub id: String,
pub target: TaskTarget, pub target: TaskTarget,
pub outputs: Vec<String>, pub outputs: Vec<String>,
pub project_root: Option<String>, pub project_root: Option<String>,
pub start_time: Option<f64>, pub start_time: Option<i64>,
pub end_time: Option<f64>, pub end_time: Option<i64>,
pub continuous: Option<bool>, pub continuous: Option<bool>,
} }
#[napi(object)] #[napi(object)]
#[derive(Default, Clone)] #[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct TaskTarget { pub struct TaskTarget {
pub project: String, pub project: String,
pub target: String, pub target: String,
@ -27,7 +27,7 @@ pub struct TaskTarget {
} }
#[napi(object)] #[napi(object)]
#[derive(Default, Clone)] #[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct TaskResult { pub struct TaskResult {
pub task: Task, pub task: Task,
pub status: String, pub status: String,

View File

@ -1,3 +1,7 @@
use crate::native::tasks::types::{Task, TaskResult};
use super::{app::Focus, components::tasks_list::TaskStatus};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action { pub enum Action {
Tick, Tick,
@ -13,13 +17,21 @@ pub enum Action {
RemoveFilterChar, RemoveFilterChar,
ScrollUp, ScrollUp,
ScrollDown, ScrollDown,
NextTask, PinTask(String, usize),
PreviousTask, UnpinTask(String, usize),
UnpinAllTasks,
SortTasks,
NextPage, NextPage,
PreviousPage, PreviousPage,
ToggleOutput, NextTask,
FocusNext, PreviousTask,
FocusPrevious, SetSpacebarMode(bool),
ScrollPaneUp(usize), ScrollPaneUp(usize),
ScrollPaneDown(usize), ScrollPaneDown(usize),
UpdateTaskStatus(String, TaskStatus),
UpdateCloudMessage(String),
UpdateFocus(Focus),
StartCommand(Option<u32>),
StartTasks(Vec<Task>),
EndTasks(Vec<TaskResult>),
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent}; use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::Rect; use ratatui::layout::{Rect, Size};
use std::any::Any; use std::any::Any;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
@ -12,40 +12,110 @@ use super::{
pub mod countdown_popup; pub mod countdown_popup;
pub mod help_popup; pub mod help_popup;
pub mod help_text; pub mod help_text;
pub mod layout_manager;
pub mod pagination; pub mod pagination;
pub mod task_selection_manager; pub mod task_selection_manager;
pub mod tasks_list; pub mod tasks_list;
pub mod terminal_pane; pub mod terminal_pane;
/// `Component` is a trait that represents a visual and interactive element of the user interface.
///
/// Implementors of this trait can be registered with the main application loop and will be able to
/// receive events, update state, and be rendered on the screen.
pub trait Component: Any + Send { pub trait Component: Any + Send {
#[allow(unused_variables)] /// Register an action handler that can send actions for processing if necessary.
///
/// # Arguments
///
/// * `tx` - An unbounded sender that can send actions.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
let _ = tx; // to appease clippy
Ok(()) Ok(())
} }
fn init(&mut self) -> Result<()> { /// Initialize the component with a specified area if necessary.
///
/// # Arguments
///
/// * `area` - Rectangular area to initialize the component within.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn init(&mut self, area: Size) -> Result<()> {
let _ = area; // to appease clippy
Ok(()) Ok(())
} }
/// Handle incoming events and produce actions if necessary.
///
/// # Arguments
///
/// * `event` - An optional event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> { fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
let r = match event { let action = match event {
Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
_ => None, _ => None,
}; };
Ok(r) Ok(action)
} }
#[allow(unused_variables)] /// Handle key events and produce actions if necessary.
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> { ///
/// # Arguments
///
/// * `key` - A key event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
let _ = key; // to appease clippy
Ok(None) Ok(None)
} }
#[allow(unused_variables)] /// Handle mouse events and produce actions if necessary.
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> { ///
/// # Arguments
///
/// * `mouse` - A mouse event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
let _ = mouse; // to appease clippy
Ok(None) Ok(None)
} }
#[allow(unused_variables)] /// Update the state of the component based on a received action. (REQUIRED)
///
/// # Arguments
///
/// * `action` - An action that may modify the state of the component.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn update(&mut self, action: Action) -> Result<Option<Action>> { fn update(&mut self, action: Action) -> Result<Option<Action>> {
let _ = action; // to appease clippy
Ok(None) Ok(None)
} }
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>; /// Render the component on the screen. (REQUIRED)
///
/// # Arguments
///
/// * `f` - A frame used for rendering.
/// * `area` - The area in which the component should be drawn.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
fn as_any(&self) -> &dyn Any; fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any;

View File

@ -98,16 +98,31 @@ impl CountdownPopup {
} }
pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) { pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0 || area.width == 0 ||
area.x >= f.area().width ||
area.y >= f.area().height {
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let popup_height = 9; let popup_height = 9;
let popup_width = 70; let popup_width = 70;
// Make sure we don't exceed the available area // Make sure we don't exceed the available area
let popup_height = popup_height.min(area.height.saturating_sub(4)); let popup_height = popup_height.min(safe_area.height.saturating_sub(4));
let popup_width = popup_width.min(area.width.saturating_sub(4)); let popup_width = popup_width.min(safe_area.width.saturating_sub(4));
// Calculate the top-left position to center the popup // Calculate the top-left position to center the popup
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; let popup_x = safe_area.x + (safe_area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; let popup_y = safe_area.y + (safe_area.height.saturating_sub(popup_height)) / 2;
// Create popup area with fixed dimensions // Create popup area with fixed dimensions
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);

View File

@ -9,6 +9,9 @@ use ratatui::{
}, },
}; };
use std::any::Any; use std::any::Any;
use tokio::sync::mpsc::UnboundedSender;
use crate::native::tui::action::Action;
use super::{Component, Frame}; use super::{Component, Frame};
@ -18,6 +21,7 @@ pub struct HelpPopup {
content_height: usize, content_height: usize,
viewport_height: usize, viewport_height: usize,
visible: bool, visible: bool,
action_tx: Option<UnboundedSender<Action>>,
} }
impl HelpPopup { impl HelpPopup {
@ -28,6 +32,7 @@ impl HelpPopup {
content_height: 0, content_height: 0,
viewport_height: 0, viewport_height: 0,
visible: false, visible: false,
action_tx: None,
} }
} }
@ -67,6 +72,23 @@ impl HelpPopup {
} }
pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) { pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0
|| area.width == 0
|| area.x >= f.area().width
|| area.y >= f.area().height
{
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let percent_y = 85; let percent_y = 85;
let percent_x = 70; let percent_x = 70;
@ -77,7 +99,7 @@ impl HelpPopup {
Constraint::Percentage(percent_y), Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage((100 - percent_y) / 2),
]) ])
.split(area); .split(safe_area);
let popup_area = Layout::default() let popup_area = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
@ -110,6 +132,10 @@ impl HelpPopup {
("<esc>", "Set focus back to task list"), ("<esc>", "Set focus back to task list"),
("<space>", "Quick toggle a single output pane"), ("<space>", "Quick toggle a single output pane"),
("b", "Toggle task list visibility"), ("b", "Toggle task list visibility"),
(
"m",
"Cycle through layout modes: auto, vertical, horizontal",
),
("1", "Pin task to be shown in output pane 1"), ("1", "Pin task to be shown in output pane 1"),
("2", "Pin task to be shown in output pane 2"), ("2", "Pin task to be shown in output pane 2"),
( (
@ -322,11 +348,17 @@ impl Clone for HelpPopup {
content_height: self.content_height, content_height: self.content_height,
viewport_height: self.viewport_height, viewport_height: self.viewport_height,
visible: self.visible, 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<()> {
self.action_tx = Some(tx);
Ok(())
}
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 {
self.render(f, rect); self.render(f, rect);
@ -334,6 +366,16 @@ impl Component for HelpPopup {
Ok(()) Ok(())
} }
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Resize(w, h) => {
self.handle_resize(w, h);
}
_ => {}
}
Ok(None)
}
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
self self
} }

View File

@ -26,6 +26,21 @@ impl HelpText {
} }
pub fn render(&self, f: &mut Frame<'_>, area: Rect) { pub fn render(&self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0 || area.width == 0 ||
area.x >= f.area().width ||
area.y >= f.area().height {
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let base_style = if self.is_dimmed { let base_style = if self.is_dimmed {
Style::default().add_modifier(Modifier::DIM) Style::default().add_modifier(Modifier::DIM)
} else { } else {
@ -47,7 +62,7 @@ impl HelpText {
} else { } else {
Alignment::Right Alignment::Right
}), }),
area, safe_area,
); );
} else { } else {
// Show full shortcuts // Show full shortcuts
@ -76,7 +91,7 @@ impl HelpText {
f.render_widget( f.render_widget(
Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Center), Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Center),
area, safe_area,
); );
} }
} }

View File

@ -0,0 +1,964 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Represents the available layout modes for the TUI application.
///
/// - `Auto`: Layout is determined based on available terminal space
/// - `Vertical`: Forces vertical layout (task list above terminal panes)
/// - `Horizontal`: Forces horizontal layout (task list beside terminal panes)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutMode {
Auto,
Vertical,
Horizontal,
}
impl Default for LayoutMode {
fn default() -> Self {
Self::Auto
}
}
/// Represents the possible arrangements of terminal panes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneArrangement {
/// No terminal panes are visible.
None,
/// Only one terminal pane is visible.
Single,
/// Two terminal panes are visible, side by side or stacked.
Double,
}
/// Represents the visibility state of the task list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskListVisibility {
Visible,
Hidden,
}
impl Default for TaskListVisibility {
fn default() -> Self {
Self::Visible
}
}
/// Configuration for the layout calculations.
#[derive(Debug, Clone)]
pub struct LayoutConfig {
/// The mode that determines how components are arranged.
pub mode: LayoutMode,
/// The visibility state of the task list.
pub task_list_visibility: TaskListVisibility,
/// The arrangement of terminal panes.
pub pane_arrangement: PaneArrangement,
/// The total number of tasks.
pub task_count: usize,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
mode: LayoutMode::Auto,
task_list_visibility: TaskListVisibility::Visible,
pane_arrangement: PaneArrangement::None,
task_count: 5,
}
}
}
/// Output of layout calculations containing areas for each component.
#[derive(Debug, Clone)]
pub struct LayoutAreas {
/// Area for the task list, if visible.
pub task_list: Option<Rect>,
/// Areas for terminal panes.
pub terminal_panes: Vec<Rect>,
}
/// Manages the layout of components in the TUI application.
///
/// The LayoutManager is responsible for calculating the optimal layout
/// for the task list and terminal panes based on the available terminal
/// space and the configured layout mode.
pub struct LayoutManager {
/// The current layout mode.
mode: LayoutMode,
/// The minimum width required for a horizontal layout to be viable.
min_horizontal_width: u16,
/// The minimum height required for a vertical layout to be viable.
min_vertical_height: u16,
/// The arrangement of terminal panes.
pane_arrangement: PaneArrangement,
/// The visibility state of the task list.
task_list_visibility: TaskListVisibility,
/// The total number of tasks.
task_count: usize,
/// Padding between task list and terminal panes in horizontal layout (left-right).
horizontal_padding: u16,
/// Padding between task list and terminal panes in vertical layout (top-bottom).
vertical_padding: u16,
}
impl LayoutManager {
/// Creates a new LayoutManager with default settings.
pub fn new(task_count: usize) -> Self {
Self {
mode: LayoutMode::Auto,
// TODO: figure out these values
min_horizontal_width: 120, // Minimum width for horizontal layout to be viable
min_vertical_height: 30, // Minimum height for vertical layout to be viable
pane_arrangement: PaneArrangement::None,
task_list_visibility: TaskListVisibility::Visible,
task_count,
horizontal_padding: 2, // Default horizontal padding of 2 characters
vertical_padding: 1, // Default vertical padding of 1 character
}
}
/// Sets the layout mode.
pub fn set_mode(&mut self, mode: LayoutMode) {
self.mode = mode;
}
/// Gets the current layout mode.
pub fn get_mode(&self) -> LayoutMode {
self.mode
}
/// Cycles the layout mode.
pub fn cycle_layout_mode(&mut self) {
self.mode = match self.mode {
LayoutMode::Auto => LayoutMode::Vertical,
LayoutMode::Vertical => LayoutMode::Horizontal,
LayoutMode::Horizontal => LayoutMode::Auto,
};
}
/// Sets the pane arrangement.
pub fn set_pane_arrangement(&mut self, pane_arrangement: PaneArrangement) {
self.pane_arrangement = pane_arrangement;
}
/// Gets the current pane arrangement.
pub fn get_pane_arrangement(&self) -> PaneArrangement {
self.pane_arrangement
}
/// Sets the task list visibility.
pub fn set_task_list_visibility(&mut self, visibility: TaskListVisibility) {
self.task_list_visibility = visibility;
}
/// Gets the current task list visibility.
pub fn get_task_list_visibility(&self) -> TaskListVisibility {
self.task_list_visibility
}
/// Sets the task count.
pub fn set_task_count(&mut self, count: usize) {
self.task_count = count;
}
/// Gets the current task count.
pub fn get_task_count(&self) -> usize {
self.task_count
}
/// Sets the horizontal padding between task list and terminal panes.
pub fn set_horizontal_padding(&mut self, padding: u16) {
self.horizontal_padding = padding;
}
/// Gets the current horizontal padding between task list and terminal panes.
pub fn get_horizontal_padding(&self) -> u16 {
self.horizontal_padding
}
/// Sets the vertical padding between task list and terminal panes.
pub fn set_vertical_padding(&mut self, padding: u16) {
self.vertical_padding = padding;
}
/// Gets the current vertical padding between task list and terminal panes.
pub fn get_vertical_padding(&self) -> u16 {
self.vertical_padding
}
/// Calculates the layout based on the given terminal area.
///
/// Returns a LayoutAreas struct containing the calculated areas for each component.
pub fn calculate_layout(&self, area: Rect) -> LayoutAreas {
// Basic bounds checking to prevent crashes
if area.width == 0 || area.height == 0 {
return LayoutAreas {
task_list: None,
terminal_panes: Vec::new(),
};
}
match self.task_list_visibility {
TaskListVisibility::Hidden => self.calculate_layout_hidden_task_list(area),
TaskListVisibility::Visible => self.calculate_layout_visible_task_list(area),
}
}
/// Calculates the layout when the task list is hidden.
fn calculate_layout_hidden_task_list(&self, area: Rect) -> LayoutAreas {
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![area],
PaneArrangement::Double => {
// Split the area into two equal parts
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
vec![chunks[0], chunks[1]]
}
};
LayoutAreas {
task_list: None,
terminal_panes,
}
}
/// Calculates the layout when the task list is visible.
fn calculate_layout_visible_task_list(&self, area: Rect) -> LayoutAreas {
// Determine whether to use vertical or horizontal layout
let use_vertical = match self.mode {
LayoutMode::Auto => {
self.is_vertical_layout_preferred(area.width, area.height, self.task_count)
}
LayoutMode::Vertical => true,
LayoutMode::Horizontal => false,
};
if use_vertical {
self.calculate_vertical_layout(area)
} else {
self.calculate_horizontal_layout(area)
}
}
/// Calculates a vertical layout (task list above terminal panes).
fn calculate_vertical_layout(&self, area: Rect) -> LayoutAreas {
// If no panes, task list gets the full area
if self.pane_arrangement == PaneArrangement::None {
return LayoutAreas {
task_list: Some(area),
terminal_panes: Vec::new(),
};
}
// Prevent divide-by-zero
let task_list_height = if area.height < 3 {
1
} else {
area.height / 3
};
// Apply padding only if there's enough space
let padding_height = if area.height > task_list_height + self.vertical_padding {
self.vertical_padding
} else {
0
};
// Ensure terminal pane has at least 1 row
let available_height = area.height.saturating_sub(task_list_height);
let terminal_pane_height = available_height.saturating_sub(padding_height);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(task_list_height),
Constraint::Length(padding_height),
Constraint::Length(terminal_pane_height),
])
.split(area);
let task_list_area = chunks[0];
let terminal_pane_area = chunks[2]; // Skip the padding area (chunks[1])
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![terminal_pane_area],
PaneArrangement::Double => {
// Split the terminal pane area horizontally for two panes
let pane_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(terminal_pane_area);
vec![pane_chunks[0], pane_chunks[1]]
}
};
LayoutAreas {
task_list: Some(task_list_area),
terminal_panes,
}
}
/// Calculates a horizontal layout (task list beside terminal panes).
fn calculate_horizontal_layout(&self, area: Rect) -> LayoutAreas {
// If no panes, task list gets the full area
if self.pane_arrangement == PaneArrangement::None {
return LayoutAreas {
task_list: Some(area),
terminal_panes: Vec::new(),
};
}
// Prevent divide-by-zero
let task_list_width = if area.width < 3 {
1
} else {
area.width / 3
};
// Apply padding only if there's enough space
let padding_width = if area.width > task_list_width + self.horizontal_padding {
self.horizontal_padding
} else {
0
};
// Ensure terminal pane has at least 1 column
let available_width = area.width.saturating_sub(task_list_width);
let terminal_pane_width = available_width.saturating_sub(padding_width);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(task_list_width),
Constraint::Length(padding_width),
Constraint::Length(terminal_pane_width),
])
.split(area);
let task_list_area = chunks[0];
let terminal_pane_area = chunks[2]; // Skip the padding area (chunks[1])
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![terminal_pane_area],
PaneArrangement::Double => {
// For two panes, split the terminal pane area vertically
let pane_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(terminal_pane_area);
vec![pane_chunks[0], pane_chunks[1]]
}
};
LayoutAreas {
task_list: Some(task_list_area),
terminal_panes,
}
}
/// Creates a LayoutConfig from the current internal state.
pub fn create_config(&self) -> LayoutConfig {
LayoutConfig {
mode: self.mode,
task_list_visibility: self.task_list_visibility,
pane_arrangement: self.pane_arrangement,
task_count: self.task_count,
}
}
/// Sets the minimum width required for a horizontal layout.
pub fn set_min_horizontal_width(&mut self, width: u16) {
self.min_horizontal_width = width;
}
/// Sets the minimum height required for a vertical layout.
pub fn set_min_vertical_height(&mut self, height: u16) {
self.min_vertical_height = height;
}
/// Determines if a vertical layout is preferred based on terminal dimensions and tasks.
///
/// This is used in Auto mode to decide between vertical and horizontal layouts.
///
/// Factors that influence the decision:
/// - Terminal aspect ratio
/// - Number of tasks (single task prefers vertical layout)
/// - Minimum dimensions requirements
fn is_vertical_layout_preferred(
&self,
terminal_width: u16,
terminal_height: u16,
task_count: usize,
) -> bool {
// If there's only a single task, prefer vertical layout
if task_count <= 1 {
return true;
}
// Calculate aspect ratio (width/height)
let aspect_ratio = terminal_width as f32 / terminal_height as f32;
// If very wide and not very tall, prefer horizontal
if aspect_ratio > 2.0 && terminal_height < self.min_vertical_height {
return false;
}
// If very tall and not very wide, prefer vertical
if aspect_ratio < 1.0 && terminal_width < self.min_horizontal_width {
return true;
}
// Otherwise, prefer horizontal for wider terminals, vertical for taller ones
aspect_ratio < 1.5
}
}
impl Default for LayoutManager {
fn default() -> Self {
Self::new(5)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_area(width: u16, height: u16) -> Rect {
Rect::new(0, 0, width, height)
}
#[test]
fn test_default_mode_is_auto() {
let layout_manager = LayoutManager::new(5);
assert_eq!(layout_manager.get_mode(), LayoutMode::Auto);
}
#[test]
fn test_default_properties() {
let layout_manager = LayoutManager::new(5);
assert_eq!(layout_manager.get_pane_arrangement(), PaneArrangement::None);
assert_eq!(layout_manager.get_task_list_visibility(), TaskListVisibility::Visible);
assert_eq!(layout_manager.get_task_count(), 5);
}
#[test]
fn test_set_properties() {
let mut layout_manager = LayoutManager::new(5);
// Test setting pane arrangement
layout_manager.set_pane_arrangement(PaneArrangement::Double);
assert_eq!(
layout_manager.get_pane_arrangement(),
PaneArrangement::Double
);
// Test setting task list visibility
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
assert_eq!(
layout_manager.get_task_list_visibility(),
TaskListVisibility::Hidden
);
// Test setting task count
layout_manager.set_task_count(10);
assert_eq!(layout_manager.get_task_count(), 10);
}
#[test]
fn test_hidden_task_list_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_none());
assert_eq!(layout.terminal_panes.len(), 1);
assert_eq!(layout.terminal_panes[0], area);
}
#[test]
fn test_hidden_task_list_with_double_panes() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_none());
assert_eq!(layout.terminal_panes.len(), 2);
assert_eq!(layout.terminal_panes[0].width, 50); // Half of total width
assert_eq!(layout.terminal_panes[1].width, 50);
}
#[test]
fn test_auto_mode_selects_horizontal_for_wide_terminal() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// In horizontal layout, task list should be on the left, taking about 1/3 of width
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200 / 3);
assert_eq!(task_list.height, 60);
}
#[test]
fn test_auto_mode_selects_vertical_for_tall_terminal() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(80, 100); // Tall terminal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// In vertical layout, task list should be on top, taking about 1/3 of height
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 80);
assert_eq!(task_list.height, 100 / 3);
}
#[test]
fn test_auto_mode_prefers_vertical_for_single_task() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal that would normally use horizontal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1); // Single task
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is wide, layout should be vertical for a single task
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200);
assert_eq!(task_list.height, 60 / 3);
}
#[test]
fn test_forced_vertical_mode() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal that would normally use horizontal
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is wide, layout should be vertical
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200);
assert_eq!(task_list.height, 60 / 3);
}
#[test]
fn test_forced_horizontal_mode() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(80, 100); // Tall terminal that would normally use vertical
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is tall, layout should be horizontal
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 80 / 3);
assert_eq!(task_list.height, 100);
}
#[test]
fn test_double_panes_in_vertical_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 80);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert_eq!(layout.terminal_panes.len(), 2);
// In vertical layout with two panes, they should be side by side
// below the task list
assert_eq!(layout.terminal_panes[0].width, 50); // Half of total width
assert_eq!(layout.terminal_panes[1].width, 50);
assert_eq!(
layout.terminal_panes[0].height,
layout.terminal_panes[1].height
);
assert!(layout.terminal_panes[0].y > layout.task_list.unwrap().y);
}
#[test]
fn test_double_panes_in_horizontal_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(150, 60);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert_eq!(layout.terminal_panes.len(), 2);
// In horizontal layout with two panes, they should be stacked
// to the right of the task list
assert_eq!(layout.terminal_panes[0].height, 30); // Half of total height
assert_eq!(layout.terminal_panes[1].height, 30);
assert_eq!(
layout.terminal_panes[0].width,
layout.terminal_panes[1].width
);
assert!(layout.terminal_panes[0].x > layout.task_list.unwrap().x);
}
#[test]
fn test_no_panes() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::None);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
assert_eq!(layout.terminal_panes.len(), 0);
}
#[test]
fn test_create_config() {
let mut layout_manager = LayoutManager::new(5);
// Set custom values for all properties
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_task_count(8);
// Create config from internal state
let config = layout_manager.create_config();
// Verify all properties match
assert_eq!(config.mode, LayoutMode::Vertical);
assert_eq!(config.pane_arrangement, PaneArrangement::Double);
assert_eq!(config.task_list_visibility, TaskListVisibility::Hidden);
assert_eq!(config.task_count, 8);
}
#[test]
fn test_padding_between_components() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 60);
// Test with default horizontal padding (2)
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap between task list and terminal pane should equal the horizontal padding
assert_eq!(terminal_pane.x - (task_list.x + task_list.width), 2);
// Test with increased horizontal padding
layout_manager.set_horizontal_padding(3);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should now be 3
assert_eq!(terminal_pane.x - (task_list.x + task_list.width), 3);
// Test with vertical layout and default vertical padding (1)
layout_manager.set_mode(LayoutMode::Vertical);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should be vertical and equal to the vertical padding
assert_eq!(terminal_pane.y - (task_list.y + task_list.height), 1);
// Test with increased vertical padding
layout_manager.set_vertical_padding(2);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should now be 2
assert_eq!(terminal_pane.y - (task_list.y + task_list.height), 2);
}
#[cfg(test)]
mod visual_tests {
use super::*;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders};
use ratatui::{backend::TestBackend, Terminal};
/// Render a layout configuration to a TestBackend for visualization
fn render_layout(
width: u16,
height: u16,
layout_manager: &LayoutManager,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let areas = layout_manager.calculate_layout(frame.area());
// 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));
frame.render_widget(task_list_block, task_list_area);
}
// Render terminal panes
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));
frame.render_widget(pane_block, *pane_area);
}
})
.unwrap();
terminal
}
/// Visual test for horizontal layout with two panes
#[test]
fn test_visualize_horizontal_layout_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for vertical layout with two panes
#[test]
fn test_visualize_vertical_layout_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for hidden task list with two panes
#[test]
fn test_visualize_hidden_task_list_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with varied terminal sizes
#[test]
fn test_visualize_auto_mode_wide_terminal() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(120, 30, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with single task (should be vertical regardless of terminal size)
#[test]
fn test_visualize_auto_mode_single_task() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1);
let terminal = render_layout(120, 30, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with tall terminal
#[test]
fn test_visualize_auto_mode_tall_terminal() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(80, 60, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for single pane layout
#[test]
fn test_visualize_single_pane() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test using different layout configurations
#[test]
fn test_visualize_different_configurations() {
// Create a layout manager with one configuration
let mut layout_manager1 = LayoutManager::new(5);
layout_manager1.set_mode(LayoutMode::Horizontal);
layout_manager1.set_pane_arrangement(PaneArrangement::Single);
// Create another layout manager with different settings
let mut layout_manager2 = LayoutManager::new(5);
layout_manager2.set_mode(LayoutMode::Vertical);
layout_manager2.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager2.set_pane_arrangement(PaneArrangement::Double);
layout_manager2.set_task_count(3);
// Render both for visual comparison
let terminal_config1 = render_layout(100, 40, &layout_manager1);
insta::assert_snapshot!("config1", terminal_config1.backend());
let terminal_config2 = render_layout(100, 40, &layout_manager2);
insta::assert_snapshot!("config2", terminal_config2.backend());
}
/// Visual test for default case - no panes and full width task list
#[test]
fn test_visualize_no_panes_full_width_task_list() {
let layout_manager = LayoutManager::new(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
// These tests will run even without the snapshot feature enabled
// to verify layout calculations are correct
#[test]
fn test_verify_horizontal_layout_areas() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 40);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
// Verify task list exists and is on the left
assert!(layout.task_list.is_some());
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.width, 100 / 3);
assert_eq!(task_list.height, 40);
// Verify we have two panes to the right of the task list
assert_eq!(layout.terminal_panes.len(), 2);
assert!(layout.terminal_panes[0].x > task_list.x);
assert!(layout.terminal_panes[1].x > task_list.x);
// Verify panes are stacked
assert_eq!(layout.terminal_panes[0].height, 20);
assert_eq!(layout.terminal_panes[1].height, 20);
assert!(layout.terminal_panes[1].y > layout.terminal_panes[0].y);
}
#[test]
fn test_verify_vertical_layout_areas() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 40);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
// Verify task list exists and is on top
assert!(layout.task_list.is_some());
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 100);
assert_eq!(task_list.height, 40 / 3);
// Verify we have two panes below the task list
assert_eq!(layout.terminal_panes.len(), 2);
assert!(layout.terminal_panes[0].y > task_list.y);
assert!(layout.terminal_panes[1].y > task_list.y);
// Verify panes are side by side
assert_eq!(layout.terminal_panes[0].width, 50);
assert_eq!(layout.terminal_panes[1].width, 50);
assert!(layout.terminal_panes[1].x > layout.terminal_panes[0].x);
}
}
}

View File

@ -19,7 +19,25 @@ impl Pagination {
} }
} }
/// Renders the pagination at the given location with the specified focus state.
pub fn render(&self, f: &mut Frame<'_>, area: Rect, is_dimmed: bool) { pub fn render(&self, f: &mut Frame<'_>, area: Rect, is_dimmed: bool) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0
|| area.width == 0
|| area.x >= f.area().width
|| area.y >= f.area().height
{
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let base_style = if is_dimmed { let base_style = if is_dimmed {
Style::default().add_modifier(Modifier::DIM) Style::default().add_modifier(Modifier::DIM)
} else { } else {
@ -59,6 +77,6 @@ impl Pagination {
let pagination_line = Line::from(spans); let pagination_line = Line::from(spans);
let pagination = Paragraph::new(pagination_line); let pagination = Paragraph::new(pagination_line);
f.render_widget(pagination, area); f.render_widget(pagination, safe_area);
} }
} }

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal_config1.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal_config2.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

View File

@ -0,0 +1,34 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1───────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,64 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1───────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,34 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────┐ ┌Terminal Pane 1─────────────────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└──────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: output
---
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ └───────────────────────────────────────────────────────────────┘"
"│ │ ┌Terminal Pane 2────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

File diff suppressed because it is too large Load Diff

View File

@ -287,6 +287,36 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
type State = TerminalPaneState; type State = TerminalPaneState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Add bounds checking to prevent panic when terminal is too narrow
// Safety check: ensure area is at least 5x5 to render anything properly
if area.width < 5 || area.height < 5 {
// Just render a minimal indicator instead of a full pane
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(buf.area().width.saturating_sub(area.x)),
height: area.height.min(buf.area().height.saturating_sub(area.y)),
};
if safe_area.width > 0 && safe_area.height > 0 {
// Only attempt to render if we have a valid area
let text = "...";
let paragraph = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
Widget::render(paragraph, safe_area, buf);
}
return;
}
// Ensure the area doesn't extend beyond buffer boundaries
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(buf.area().width.saturating_sub(area.x)),
height: area.height.min(buf.area().height.saturating_sub(area.y)),
};
let base_style = self.get_base_style(state.task_status); let base_style = self.get_base_style(state.task_status);
let border_style = if state.is_focused { let border_style = if state.is_focused {
base_style base_style
@ -319,7 +349,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default()); .style(Style::default());
Widget::render(paragraph, area, buf); Widget::render(paragraph, safe_area, buf);
return; return;
} }
@ -342,7 +372,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default()); .style(Style::default());
Widget::render(paragraph, area, buf); Widget::render(paragraph, safe_area, buf);
return; return;
} }
@ -365,7 +395,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default()); .style(Style::default());
Widget::render(paragraph, area, buf); Widget::render(paragraph, safe_area, buf);
return; return;
} }
@ -386,11 +416,11 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default()); .style(Style::default());
Widget::render(paragraph, area, buf); Widget::render(paragraph, safe_area, buf);
return; return;
} }
let inner_area = block.inner(area); let inner_area = block.inner(safe_area);
if let Some(pty_data) = &self.pty_data { if let Some(pty_data) = &self.pty_data {
if let Some(pty) = &pty_data.pty { if let Some(pty) = &pty_data.pty {
@ -416,7 +446,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
}; };
let pseudo_term = PseudoTerminal::new(&*screen).block(block); let pseudo_term = PseudoTerminal::new(&*screen).block(block);
Widget::render(pseudo_term, area, buf); Widget::render(pseudo_term, safe_area, buf);
// Only render scrollbar if needed // Only render scrollbar if needed
if needs_scrollbar { if needs_scrollbar {
@ -426,10 +456,10 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.end_symbol(Some("")) .end_symbol(Some(""))
.style(border_style); .style(border_style);
scrollbar.render(area, buf, &mut state.scrollbar_state); scrollbar.render(safe_area, buf, &mut state.scrollbar_state);
} }
// Show interactive/readonly status for focused, non-cache hit, tasks // Show interactive/readonly status for focused, in progress tasks
if state.task_status == TaskStatus::InProgress && state.is_focused { if state.task_status == TaskStatus::InProgress && state.is_focused {
// Bottom right status // Bottom right status
let bottom_text = if self.is_currently_interactive() { let bottom_text = if self.is_currently_interactive() {
@ -458,17 +488,20 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.map(|span| span.content.len()) .map(|span| span.content.len())
.sum::<usize>(); .sum::<usize>();
let bottom_right_area = Rect { // Ensure status text doesn't extend past safe area
x: area.x + area.width - text_width as u16 - 3, if text_width as u16 + 3 < safe_area.width {
y: area.y + area.height - 1, let bottom_right_area = Rect {
width: text_width as u16 + 2, x: safe_area.x + safe_area.width - text_width as u16 - 3,
height: 1, y: safe_area.y + safe_area.height - 1,
}; width: text_width as u16 + 2,
height: 1,
};
Paragraph::new(bottom_text) Paragraph::new(bottom_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(border_style) .style(border_style)
.render(bottom_right_area, buf); .render(bottom_right_area, buf);
}
// Top right status // Top right status
let top_text = if self.is_currently_interactive() { let top_text = if self.is_currently_interactive() {
@ -489,47 +522,53 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.map(|span| span.content.len()) .map(|span| span.content.len())
.sum::<usize>(); .sum::<usize>();
let top_right_area = Rect { // Ensure status text doesn't extend past safe area
x: area.x + area.width - mode_width as u16 - 3, if mode_width as u16 + 3 < safe_area.width {
y: area.y, let top_right_area = Rect {
width: mode_width as u16 + 2, x: safe_area.x + safe_area.width - mode_width as u16 - 3,
height: 1, y: safe_area.y,
}; width: mode_width as u16 + 2,
height: 1,
};
Paragraph::new(top_text) Paragraph::new(top_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(border_style) .style(border_style)
.render(top_right_area, buf); .render(top_right_area, buf);
}
} else if needs_scrollbar { } else if needs_scrollbar {
// Render padding for both top and bottom when scrollbar is present // Render padding for both top and bottom when scrollbar is present
let padding_text = Line::from(vec![Span::raw(" ")]); let padding_text = Line::from(vec![Span::raw(" ")]);
let padding_width = 2; let padding_width = 2;
// Top padding // Ensure paddings don't extend past safe area
let top_right_area = Rect { if padding_width + 3 < safe_area.width {
x: area.x + area.width - padding_width - 3, // Top padding
y: area.y, let top_right_area = Rect {
width: padding_width + 2, x: safe_area.x + safe_area.width - padding_width - 3,
height: 1, y: safe_area.y,
}; width: padding_width + 2,
height: 1,
};
Paragraph::new(padding_text.clone()) Paragraph::new(padding_text.clone())
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(border_style) .style(border_style)
.render(top_right_area, buf); .render(top_right_area, buf);
// Bottom padding // Bottom padding
let bottom_right_area = Rect { let bottom_right_area = Rect {
x: area.x + area.width - padding_width - 3, x: safe_area.x + safe_area.width - padding_width - 3,
y: area.y + area.height - 1, y: safe_area.y + safe_area.height - 1,
width: padding_width + 2, width: padding_width + 2,
height: 1, height: 1,
}; };
Paragraph::new(padding_text) Paragraph::new(padding_text)
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(border_style) .style(border_style)
.render(bottom_right_area, buf); .render(bottom_right_area, buf);
}
} }
} }
} }

View File

@ -1,3 +1,4 @@
use hashbrown::HashSet;
use napi::bindgen_prelude::*; use napi::bindgen_prelude::*;
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
use napi::JsObject; use napi::JsObject;
@ -53,6 +54,12 @@ impl From<(TuiConfig, &RustTuiCliArgs)> for RustTuiConfig {
} }
} }
#[napi]
pub enum RunMode {
RunOne,
RunMany,
}
#[napi] #[napi]
#[derive(Clone)] #[derive(Clone)]
pub struct AppLifeCycle { pub struct AppLifeCycle {
@ -64,6 +71,8 @@ impl AppLifeCycle {
#[napi(constructor)] #[napi(constructor)]
pub fn new( pub fn new(
tasks: Vec<Task>, tasks: Vec<Task>,
initiating_tasks: Vec<String>,
run_mode: RunMode,
pinned_tasks: Vec<String>, pinned_tasks: Vec<String>,
tui_cli_args: TuiCliArgs, tui_cli_args: TuiCliArgs,
tui_config: TuiConfig, tui_config: TuiConfig,
@ -75,10 +84,14 @@ impl AppLifeCycle {
// Convert JSON TUI configuration to our Rust TuiConfig // Convert JSON TUI configuration to our Rust TuiConfig
let rust_tui_config = RustTuiConfig::from((tui_config, &rust_tui_cli_args)); let rust_tui_config = RustTuiConfig::from((tui_config, &rust_tui_cli_args));
let initiating_tasks = initiating_tasks.into_iter().collect();
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().map(|t| t.into()).collect(),
initiating_tasks,
run_mode,
pinned_tasks, pinned_tasks,
rust_tui_config, rust_tui_config,
title_text, title_text,
@ -190,9 +203,14 @@ impl AppLifeCycle {
// Store callback for cleanup // Store callback for cleanup
app.set_done_callback(done_callback); app.set_done_callback(done_callback);
app.register_action_handler(action_tx.clone()).ok();
for component in app.components.iter_mut() { for component in app.components.iter_mut() {
component.register_action_handler(action_tx.clone()).ok(); component.register_action_handler(action_tx.clone()).ok();
component.init().ok(); }
app.init(tui.size().unwrap()).ok();
for component in app.components.iter_mut() {
component.init(tui.size().unwrap()).ok();
} }
} }
debug!("Initialized Components"); debug!("Initialized Components");
@ -252,7 +270,7 @@ impl AppLifeCycle {
#[napi] #[napi]
pub fn set_task_status(&mut self, task_id: String, status: TaskStatus) { pub fn set_task_status(&mut self, task_id: String, status: TaskStatus) {
let mut app = self.app.lock().unwrap(); let mut app = self.app.lock().unwrap();
app.set_task_status(task_id, status) app.update_task_status(task_id, status)
} }
#[napi] #[napi]

View File

@ -1,6 +1,8 @@
use hashbrown::HashSet;
use crate::native::tui::components::tasks_list::{TaskItem, TaskStatus}; use crate::native::tui::components::tasks_list::{TaskItem, TaskStatus};
pub fn format_duration(duration_ms: u128) -> String { pub fn format_duration(duration_ms: i64) -> String {
if duration_ms == 0 { if duration_ms == 0 {
"<1ms".to_string() "<1ms".to_string()
} else if duration_ms < 1000 { } else if duration_ms < 1000 {
@ -10,7 +12,7 @@ pub fn format_duration(duration_ms: u128) -> String {
} }
} }
pub fn format_duration_since(start_ms: u128, end_ms: u128) -> String { pub fn format_duration_since(start_ms: i64, end_ms: i64) -> String {
format_duration(end_ms.saturating_sub(start_ms)) format_duration(end_ms.saturating_sub(start_ms))
} }
@ -36,32 +38,37 @@ pub fn normalize_newlines(input: &[u8]) -> Vec<u8> {
/// ///
/// The sort order is: /// The sort order is:
/// 1. InProgress tasks first /// 1. InProgress tasks first
/// 2. Failure tasks second /// 2. Highlighted tasks second (tasks whose names appear in the highlighted_names list)
/// 3. Other completed tasks third (sorted by end_time if available) /// 3. Failure tasks third
/// 4. NotStarted tasks last /// 4. Other completed tasks fourth (sorted by end_time if available)
/// 5. NotStarted tasks last
/// ///
/// Within each status category: /// Within each status category:
/// - For completed tasks: sort by end_time if available, then by name /// - For completed tasks: sort by end_time if available, then by name
/// - For other statuses: sort by name /// - For other statuses: sort by name
pub fn sort_task_items(tasks: &mut [TaskItem]) { pub fn sort_task_items(tasks: &mut [TaskItem], highlighted_names: &HashSet<String>) {
tasks.sort_by(|a, b| { tasks.sort_by(|a, b| {
// Map status to a numeric category for sorting // Map status to a numeric category for sorting
let status_to_category = |status: &TaskStatus| -> u8 { let status_to_category = |status: &TaskStatus, name: &str| -> u8 {
if highlighted_names.contains(&name.to_string()) {
return 1; // Highlighted tasks come second
}
match status { match status {
TaskStatus::InProgress | TaskStatus::Shared => 0, TaskStatus::InProgress | TaskStatus::Shared => 0,
TaskStatus::Failure => 1, TaskStatus::Failure => 2,
TaskStatus::Success TaskStatus::Success
| TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCacheKeptExisting
| TaskStatus::LocalCache | TaskStatus::LocalCache
| TaskStatus::RemoteCache | TaskStatus::RemoteCache
| TaskStatus::Skipped | TaskStatus::Skipped
| TaskStatus::Stopped => 2, | TaskStatus::Stopped => 3,
TaskStatus::NotStarted => 3, TaskStatus::NotStarted => 4,
} }
}; };
let a_category = status_to_category(&a.status); let a_category = status_to_category(&a.status, &a.name);
let b_category = status_to_category(&b.status); let b_category = status_to_category(&b.status, &b.name);
// First compare by status category // First compare by status category
if a_category != b_category { if a_category != b_category {
@ -69,7 +76,7 @@ pub fn sort_task_items(tasks: &mut [TaskItem]) {
} }
// For completed tasks, sort by end_time if available // For completed tasks, sort by end_time if available
if a_category == 1 || a_category == 2 { if a_category == 2 || a_category == 3 {
// Failure or Success categories // Failure or Success categories
match (a.end_time, b.end_time) { match (a.end_time, b.end_time) {
(Some(time_a), Some(time_b)) => { (Some(time_a), Some(time_b)) => {
@ -94,7 +101,7 @@ mod tests {
use super::*; use super::*;
// Helper function to create a TaskItem for testing // Helper function to create a TaskItem for testing
fn create_task(name: &str, status: TaskStatus, end_time: Option<u128>) -> TaskItem { fn create_task(name: &str, status: TaskStatus, end_time: Option<i64>) -> TaskItem {
let mut task = TaskItem::new(name.to_string(), false); let mut task = TaskItem::new(name.to_string(), false);
task.status = status; task.status = status;
task.end_time = end_time; task.end_time = end_time;
@ -110,7 +117,7 @@ mod tests {
create_task("task4", TaskStatus::Failure, Some(200)), create_task("task4", TaskStatus::Failure, Some(200)),
]; ];
sort_task_items(&mut tasks); sort_task_items(&mut tasks, &HashSet::new());
// Expected order: InProgress, Failure, Success, NotStarted // Expected order: InProgress, Failure, Success, NotStarted
assert_eq!(tasks[0].status, TaskStatus::InProgress); assert_eq!(tasks[0].status, TaskStatus::InProgress);
@ -119,6 +126,31 @@ mod tests {
assert_eq!(tasks[3].status, TaskStatus::NotStarted); assert_eq!(tasks[3].status, TaskStatus::NotStarted);
} }
#[test]
fn test_highlighted_tasks() {
let mut tasks = vec![
create_task("task1", TaskStatus::NotStarted, None),
create_task("task2", TaskStatus::InProgress, None),
create_task("task3", TaskStatus::Success, Some(100)),
create_task("task4", TaskStatus::Failure, Some(200)),
create_task("highlighted1", TaskStatus::NotStarted, None),
create_task("highlighted2", TaskStatus::Success, Some(300)),
];
// Highlight two tasks, one that is NotStarted and one that is Success
let highlighted = HashSet::from(["highlighted1".to_string(), "highlighted2".to_string()]);
sort_task_items(&mut tasks, &highlighted);
// Expected order: InProgress (task2), Highlighted (highlighted1, highlighted2),
// Failure (task4), Success (task3), NotStarted (task1)
assert_eq!(tasks[0].name, "task2"); // InProgress
assert!(tasks[1].name == "highlighted1" || tasks[1].name == "highlighted2");
assert!(tasks[2].name == "highlighted1" || tasks[2].name == "highlighted2");
assert_eq!(tasks[3].name, "task4"); // Failure
assert_eq!(tasks[4].name, "task3"); // Success
assert_eq!(tasks[5].name, "task1"); // NotStarted
}
#[test] #[test]
fn test_sort_completed_tasks_by_end_time() { fn test_sort_completed_tasks_by_end_time() {
let mut tasks = vec![ let mut tasks = vec![
@ -127,7 +159,8 @@ mod tests {
create_task("task3", TaskStatus::Success, Some(200)), create_task("task3", TaskStatus::Success, Some(200)),
]; ];
sort_task_items(&mut tasks); let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Should be sorted by end_time: 100, 200, 300 // Should be sorted by end_time: 100, 200, 300
assert_eq!(tasks[0].name, "task2"); assert_eq!(tasks[0].name, "task2");
@ -143,7 +176,8 @@ mod tests {
create_task("task3", TaskStatus::Success, None), create_task("task3", TaskStatus::Success, None),
]; ];
sort_task_items(&mut tasks); let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Tasks with end_time come before those without // Tasks with end_time come before those without
assert_eq!(tasks[0].name, "task2"); assert_eq!(tasks[0].name, "task2");
@ -160,7 +194,8 @@ mod tests {
create_task("b", TaskStatus::NotStarted, None), create_task("b", TaskStatus::NotStarted, None),
]; ];
sort_task_items(&mut tasks); let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Should be sorted alphabetically: a, b, c // Should be sorted alphabetically: a, b, c
assert_eq!(tasks[0].name, "a"); assert_eq!(tasks[0].name, "a");
@ -181,13 +216,8 @@ mod tests {
create_task("s", TaskStatus::NotStarted, None), create_task("s", TaskStatus::NotStarted, None),
]; ];
sort_task_items(&mut tasks); let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Expected groups by status:
// 1. InProgress: "u", "y" (alphabetical)
// 2. Failure: "w" (with end_time), "t" (without end_time)
// 3. Success: "x" (with end_time), "v" (without end_time)
// 4. NotStarted: "s", "z" (alphabetical)
// Check the order within each status group // Check the order within each status group
let names: Vec<&str> = tasks.iter().map(|t| &t.name[..]).collect(); let names: Vec<&str> = tasks.iter().map(|t| &t.name[..]).collect();
@ -226,7 +256,8 @@ mod tests {
create_task("b", TaskStatus::Success, Some(100)), create_task("b", TaskStatus::Success, Some(100)),
]; ];
sort_task_items(&mut tasks); let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// When end_times are the same, should sort by name // When end_times are the same, should sort by name
assert_eq!(tasks[0].name, "a"); assert_eq!(tasks[0].name, "a");
@ -238,8 +269,9 @@ mod tests {
fn test_sort_empty_list() { fn test_sort_empty_list() {
let mut tasks: Vec<TaskItem> = vec![]; let mut tasks: Vec<TaskItem> = vec![];
let empty_highlighted: HashSet<String> = HashSet::new();
// Should not panic on empty list // Should not panic on empty list
sort_task_items(&mut tasks); sort_task_items(&mut tasks, &empty_highlighted);
assert!(tasks.is_empty()); assert!(tasks.is_empty());
} }
@ -248,8 +280,9 @@ mod tests {
fn test_sort_single_task() { fn test_sort_single_task() {
let mut tasks = vec![create_task("task", TaskStatus::Success, Some(100))]; let mut tasks = vec![create_task("task", TaskStatus::Success, Some(100))];
let empty_highlighted: HashSet<String> = HashSet::new();
// Should not change a single-element list // Should not change a single-element list
sort_task_items(&mut tasks); sort_task_items(&mut tasks, &empty_highlighted);
assert_eq!(tasks.len(), 1); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "task"); assert_eq!(tasks[0].name, "task");
@ -266,15 +299,54 @@ mod tests {
// Mark the original positions // Mark the original positions
let original_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>(); let original_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let empty_highlighted: HashSet<String> = HashSet::new();
// Sort should maintain original order for equal elements // Sort should maintain original order for equal elements
sort_task_items(&mut tasks); sort_task_items(&mut tasks, &empty_highlighted);
let sorted_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>(); let sorted_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
assert_eq!(sorted_names, original_names); assert_eq!(sorted_names, original_names);
} }
#[test] #[test]
fn test_sort_large_random_dataset() { fn test_sort_edge_cases() {
// Test with extreme end_time values
let mut tasks = vec![
create_task("a", TaskStatus::Success, Some(i64::MAX)),
create_task("b", TaskStatus::Success, Some(0)),
create_task("c", TaskStatus::Success, Some(i64::MAX / 2)),
];
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Should sort by end_time: 0, MAX/2, MAX
assert_eq!(tasks[0].name, "b");
assert_eq!(tasks[1].name, "c");
assert_eq!(tasks[2].name, "a");
}
#[test]
fn test_highlighted_tasks_empty_list() {
let mut tasks = vec![
create_task("task1", TaskStatus::NotStarted, None),
create_task("task2", TaskStatus::InProgress, None),
create_task("task3", TaskStatus::Success, Some(100)),
create_task("task4", TaskStatus::Failure, Some(200)),
];
// Empty highlighted list should not affect sorting
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Expected order: InProgress, Failure, Success, NotStarted
assert_eq!(tasks[0].name, "task2"); // InProgress
assert_eq!(tasks[1].name, "task4"); // Failure
assert_eq!(tasks[2].name, "task3"); // Success
assert_eq!(tasks[3].name, "task1"); // NotStarted
}
#[test]
fn test_large_random_dataset() {
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
@ -303,8 +375,9 @@ mod tests {
}) })
.collect(); .collect();
let empty_highlighted: HashSet<String> = HashSet::new();
// Sort should not panic with large random dataset // Sort should not panic with large random dataset
sort_task_items(&mut tasks); sort_task_items(&mut tasks, &empty_highlighted);
// Verify the sort maintains the expected ordering rules // Verify the sort maintains the expected ordering rules
for i in 1..tasks.len() { for i in 1..tasks.len() {
@ -312,22 +385,23 @@ mod tests {
let b = &tasks[i]; let b = &tasks[i];
// Map status to category for comparison // Map status to category for comparison
let status_to_category = |status: &TaskStatus| -> u8 { let status_to_category = |status: &TaskStatus, name: &str| -> u8 {
// In this test we're using an empty highlighted list
match status { match status {
TaskStatus::InProgress | TaskStatus::Shared => 0, TaskStatus::InProgress | TaskStatus::Shared => 0,
TaskStatus::Failure => 1, TaskStatus::Failure => 2,
TaskStatus::Success TaskStatus::Success
| TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCacheKeptExisting
| TaskStatus::LocalCache | TaskStatus::LocalCache
| TaskStatus::RemoteCache | TaskStatus::RemoteCache
| TaskStatus::Stopped | TaskStatus::Stopped
| TaskStatus::Skipped => 2, | TaskStatus::Skipped => 3,
TaskStatus::NotStarted => 3, TaskStatus::NotStarted => 4,
} }
}; };
let a_category = status_to_category(&a.status); let a_category = status_to_category(&a.status, &a.name);
let b_category = status_to_category(&b.status); let b_category = status_to_category(&b.status, &b.name);
if a_category < b_category { if a_category < b_category {
// If a's category is less than b's, that's correct // If a's category is less than b's, that's correct
@ -341,7 +415,7 @@ mod tests {
} }
// Same category, check end_time for completed tasks // Same category, check end_time for completed tasks
if a_category == 1 || a_category == 2 { if a_category == 2 || a_category == 3 {
match (a.end_time, b.end_time) { match (a.end_time, b.end_time) {
(Some(time_a), Some(time_b)) => { (Some(time_a), Some(time_b)) => {
if time_a > time_b { if time_a > time_b {
@ -367,21 +441,4 @@ mod tests {
} }
} }
} }
#[test]
fn test_sort_edge_cases() {
// Test with extreme end_time values
let mut tasks = vec![
create_task("a", TaskStatus::Success, Some(u128::MAX)),
create_task("b", TaskStatus::Success, Some(0)),
create_task("c", TaskStatus::Success, Some(u128::MAX / 2)),
];
sort_task_items(&mut tasks);
// Should sort by end_time: 0, MAX/2, MAX
assert_eq!(tasks[0].name, "b");
assert_eq!(tasks[1].name, "c");
assert_eq!(tasks[2].name, "a");
}
} }

View File

@ -17,6 +17,7 @@ import {
getTaskDetails, getTaskDetails,
hashTasksThatDoNotDependOnOutputsOfOtherTasks, hashTasksThatDoNotDependOnOutputsOfOtherTasks,
} from '../hasher/hash-task'; } from '../hasher/hash-task';
import { RunMode } from '../native';
import { import {
runPostTasksExecution, runPostTasksExecution,
runPreTasksExecution, runPreTasksExecution,
@ -89,6 +90,8 @@ async function getTerminalOutputLifeCycle(
const overridesWithoutHidden = { ...overrides }; const overridesWithoutHidden = { ...overrides };
delete overridesWithoutHidden['__overrides_unparsed__']; delete overridesWithoutHidden['__overrides_unparsed__'];
const isRunOne = initiatingProject != null;
if (isTuiEnabled(nxJson)) { if (isTuiEnabled(nxJson)) {
const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = []; const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = [];
@ -188,6 +191,8 @@ async function getTerminalOutputLifeCycle(
if (tasks.length > 0) { if (tasks.length > 0) {
appLifeCycle = new AppLifeCycle( appLifeCycle = new AppLifeCycle(
tasks, tasks,
initiatingTasks.map((t) => t.id),
isRunOne ? RunMode.RunOne : RunMode.RunMany,
pinnedTasks, pinnedTasks,
nxArgs ?? {}, nxArgs ?? {},
nxJson.tui ?? {}, nxJson.tui ?? {},
@ -290,7 +295,6 @@ async function getTerminalOutputLifeCycle(
} }
const { runnerOptions } = getRunner(nxArgs, nxJson); const { runnerOptions } = getRunner(nxArgs, nxJson);
const isRunOne = initiatingProject != null;
const useDynamicOutput = shouldUseDynamicLifeCycle( const useDynamicOutput = shouldUseDynamicLifeCycle(
tasks, tasks,
runnerOptions, runnerOptions,