chore(core): add tui layout manager (#30947)
This commit is contained in:
parent
d6ea3ab45f
commit
0f4c085297
50
Cargo.lock
generated
50
Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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'],
|
||||||
};
|
};
|
||||||
|
|||||||
7
packages/nx/src/native/index.d.ts
vendored
7
packages/nx/src/native/index.d.ts
vendored
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
964
packages/nx/src/native/tui/components/layout_manager.rs
Normal file
964
packages/nx/src/native/tui/components/layout_manager.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal_config1.backend()
|
||||||
|
---
|
||||||
|
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"
|
||||||
@ -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─────────────────────────────────┐"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||||
|
" "
|
||||||
|
"┌Terminal Pane 1───────────────────────────────────────────────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"┌Task List─────────────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"└──────────────────────────────────────────────────────────────────────────────┘"
|
||||||
|
" "
|
||||||
|
"┌Terminal Pane 1───────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"└──────────────────────────────────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"┌Task List─────────────────────────────┐ ┌Terminal Pane 1─────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"└──────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"│ ││ │"
|
||||||
|
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"
|
||||||
@ -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────────────────────────────────────────────────┐"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"│ │"
|
||||||
|
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
source: packages/nx/src/native/tui/components/layout_manager.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"│ │ │ │"
|
||||||
|
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"
|
||||||
@ -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
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user