use crate::ast::parse_workflow;
use camino::Utf8Path;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
Frame,
};
use super::view_trait::View;
use super::ViewAction;
use crate::ast::schema_validator::WorkflowSchemaValidator;
use crate::ast::Workflow;
use crate::error::NikaError;
use crate::tui::diagnostics::DiagnosticsEngine;
use crate::tui::edit_history::EditHistory;
use crate::tui::git::{GitStatus, LineChange};
use crate::tui::selection::{Position, Selection, SelectionSet};
use crate::tui::state::TuiState;
use crate::tui::theme::{TaskStatus, Theme, VerbColor};
use crate::tui::views::TuiView;
use crate::tui::widgets::tree::{
build_git_status_cache, AnimationTicker, FilterConfig, GitStatusCache, TreeAction, TreeColors,
TreeFilter, TreeNode, TreeState, TreeWidget,
};
use crate::tui::widgets::{
centered_rect, CommandPalette, CommandPaletteState, DagAscii, MatrixRain, NodeBoxData,
NodeBoxMode, ScrollIndicator, WhichKey, WhichKeyState,
};
use crate::util::atomic_write;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditorMode {
#[default]
Normal,
Insert,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StudioFocus {
#[default]
Browser,
Editor,
Dag,
}
impl StudioFocus {
pub fn next(&self) -> Self {
match self {
StudioFocus::Browser => StudioFocus::Editor,
StudioFocus::Editor => StudioFocus::Dag,
StudioFocus::Dag => StudioFocus::Browser,
}
}
pub fn prev(&self) -> Self {
match self {
StudioFocus::Browser => StudioFocus::Dag,
StudioFocus::Editor => StudioFocus::Browser,
StudioFocus::Dag => StudioFocus::Editor,
}
}
pub fn title(&self) -> &'static str {
match self {
StudioFocus::Browser => "Browser",
StudioFocus::Editor => "Editor",
StudioFocus::Dag => "DAG",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StudioRatio {
#[default]
Balanced,
EditorFocus,
BrowserFocus,
DagFocus,
}
impl StudioRatio {
pub fn constraints(&self) -> [Constraint; 3] {
match self {
StudioRatio::Balanced => [
Constraint::Percentage(20),
Constraint::Percentage(50),
Constraint::Percentage(30),
],
StudioRatio::EditorFocus => [
Constraint::Percentage(15),
Constraint::Percentage(65),
Constraint::Percentage(20),
],
StudioRatio::BrowserFocus => [
Constraint::Percentage(35),
Constraint::Percentage(45),
Constraint::Percentage(20),
],
StudioRatio::DagFocus => [
Constraint::Percentage(15),
Constraint::Percentage(35),
Constraint::Percentage(50),
],
}
}
pub fn next(&self) -> Self {
match self {
StudioRatio::Balanced => StudioRatio::EditorFocus,
StudioRatio::EditorFocus => StudioRatio::BrowserFocus,
StudioRatio::BrowserFocus => StudioRatio::DagFocus,
StudioRatio::DagFocus => StudioRatio::Balanced,
}
}
pub fn title(&self) -> &'static str {
match self {
StudioRatio::Balanced => "Balanced",
StudioRatio::EditorFocus => "Editor+",
StudioRatio::BrowserFocus => "Browser+",
StudioRatio::DagFocus => "DAG+",
}
}
}
pub struct StudioView {
pub root_dir: PathBuf,
pub tree_state: TreeState,
pub tree_colors: TreeColors,
pub animation_ticker: AnimationTicker,
pub filter_config: FilterConfig,
git_cache: GitStatusCache,
git_cache_time: Instant,
cached_tree: Option<TreeNode>,
quick_access: Vec<PathBuf>,
pub editor: YamlEditorPanel,
pub focus: StudioFocus,
pub ratio: StudioRatio,
pub command_palette: CommandPaletteState,
pub which_key: WhichKeyState,
}
impl Default for StudioView {
fn default() -> Self {
Self::new()
}
}
impl StudioView {
pub fn new() -> Self {
let root_dir = std::env::current_dir().unwrap_or_default();
Self::with_root(root_dir)
}
pub fn with_root(root_dir: PathBuf) -> Self {
let root_path = Utf8Path::from_path(&root_dir).unwrap_or(Utf8Path::new("."));
let git_cache = build_git_status_cache(root_path);
let quick_access = Self::scan_nika_files(&root_dir);
Self {
root_dir,
tree_state: TreeState::default(),
tree_colors: TreeColors::default(),
animation_ticker: AnimationTicker::new(),
filter_config: FilterConfig::default(),
editor: YamlEditorPanel::new(),
focus: StudioFocus::default(),
ratio: StudioRatio::default(),
git_cache,
git_cache_time: Instant::now(),
cached_tree: None,
quick_access,
command_palette: CommandPaletteState::new(),
which_key: WhichKeyState::new(),
}
}
fn scan_nika_files(root: &PathBuf) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".nika.yaml") {
files.push(path);
}
}
}
}
let workflows_dir = root.join("workflows");
if workflows_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&workflows_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".nika.yaml") && files.len() < 5 {
files.push(path);
}
}
}
}
}
files.truncate(5);
files
}
pub fn load_file(&mut self, path: PathBuf) -> Result<(), std::io::Error> {
let result = self.editor.load_file(path);
self.focus = StudioFocus::Editor;
result
}
pub fn open_file(&mut self, path: PathBuf) {
let _ = self.load_file(path);
}
fn border_style(&self, panel: StudioFocus, theme: &Theme) -> Style {
use ratatui::style::Modifier;
if self.focus == panel {
Style::default()
.fg(theme.border_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.border_normal)
}
}
fn render_browser(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let style = self.border_style(StudioFocus::Browser, theme);
let focus_indicator = if self.focus == StudioFocus::Browser {
"●"
} else {
" "
};
let title = if self.filter_config.filter.badge().is_empty() {
format!(" {} Browser ", focus_indicator)
} else {
format!(
" {} Browser [{}] ",
focus_indicator,
self.filter_config.filter.badge()
)
};
let block = Block::default()
.title(Span::styled(title, style))
.borders(Borders::ALL)
.border_style(style);
let inner = block.inner(area);
frame.render_widget(block, area);
let quick_access_height = if self.quick_access.is_empty() {
0
} else {
(self.quick_access.len() + 2) as u16
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(quick_access_height), Constraint::Min(3)])
.split(inner);
if !self.quick_access.is_empty() {
self.render_quick_access(frame, chunks[0], theme);
}
let tree_area = chunks[1];
const GIT_CACHE_TTL: Duration = Duration::from_secs(5);
let root_path = Utf8Path::from_path(&self.root_dir).unwrap_or(Utf8Path::new("."));
if self.git_cache_time.elapsed() > GIT_CACHE_TTL {
self.git_cache = build_git_status_cache(root_path);
self.git_cache_time = Instant::now();
self.cached_tree = None;
}
let tree_rebuilt = self.cached_tree.is_none();
let root_node = if let Some(ref cached) = self.cached_tree {
cached.clone()
} else {
let tree = TreeNode::build_tree(root_path, Some(&self.git_cache), None);
self.cached_tree = Some(tree.clone());
tree
};
if tree_rebuilt {
self.tree_state.update_visible_nodes(&root_node);
self.tree_state.select_first_if_none();
if !self.tree_state.is_expanded(root_node.id) {
self.tree_state.expand(root_node.id);
self.tree_state.update_visible_nodes(&root_node);
}
}
self.tree_colors = TreeColors::solarized_dark();
let tree_widget = TreeWidget::new(&root_node)
.colors(self.tree_colors.clone())
.ticker(&self.animation_ticker)
.filter(self.filter_config.clone());
frame.render_stateful_widget(tree_widget, tree_area, &mut self.tree_state);
}
fn render_quick_access(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("⚡ ", Style::default().fg(theme.status_running)),
Span::styled(
"QUICK ACCESS",
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
),
]));
for path in &self.quick_access {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("🦋 ", Style::default()),
Span::styled(name, Style::default().fg(theme.border_focused)),
]));
}
let sep_width = area.width.saturating_sub(2) as usize;
lines.push(Line::from(Span::styled(
"─".repeat(sep_width),
Style::default().fg(theme.border_normal),
)));
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
fn render_dag_panel(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let style = self.border_style(StudioFocus::Dag, theme);
let focus_indicator = if self.focus == StudioFocus::Dag {
"●"
} else {
" "
};
let block = Block::default()
.title(Span::styled(
format!(" {} DAG Preview ", focus_indicator),
style,
))
.borders(Borders::ALL)
.border_style(style);
let inner = block.inner(area);
frame.render_widget(block, area);
let yaml = self.editor.buffer.content();
self.editor.render_dag_structure(frame, inner, &yaml, theme);
}
pub fn refresh_tree(&mut self) {
let root_path = Utf8Path::from_path(&self.root_dir).unwrap_or(Utf8Path::new("."));
self.git_cache = build_git_status_cache(root_path);
self.git_cache_time = Instant::now();
self.cached_tree = None;
}
fn get_or_build_tree(&mut self) -> TreeNode {
if let Some(ref cached) = self.cached_tree {
return cached.clone();
}
let root_path = Utf8Path::from_path(&self.root_dir).unwrap_or(Utf8Path::new("."));
let tree = TreeNode::build_tree(root_path, Some(&self.git_cache), None);
self.cached_tree = Some(tree.clone());
tree
}
fn handle_browser_key(&mut self, key: KeyEvent) -> ViewAction {
let root_node = self.get_or_build_tree();
if let Some(action) = TreeAction::from_key_event(key) {
let needs_update = matches!(
action,
TreeAction::Toggle | TreeAction::Left | TreeAction::Right
);
self.tree_state.handle_action(action, &root_node);
if needs_update {
self.tree_state.update_visible_nodes(&root_node);
}
return ViewAction::None;
}
match key.code {
KeyCode::Enter => {
if let Some(node_id) = self.tree_state.selected() {
if let Some(node) = self.tree_state.find_node(&root_node, node_id) {
let path = PathBuf::from(node.path.as_str());
if path.is_file()
&& path.extension().is_some_and(|e| e == "yaml" || e == "yml")
{
self.open_file(path);
}
}
}
ViewAction::None
}
KeyCode::Char('r') | KeyCode::Char('R') => {
self.refresh_tree();
ViewAction::None
}
KeyCode::Char('w') | KeyCode::Char('W') => {
self.filter_config.set_filter(TreeFilter::WorkflowsOnly);
self.cached_tree = None; ViewAction::None
}
KeyCode::Char('a') | KeyCode::Char('A') => {
self.filter_config.set_filter(TreeFilter::All);
self.cached_tree = None; ViewAction::None
}
KeyCode::Char('e') | KeyCode::Char('E') => {
self.filter_config.set_filter(TreeFilter::EcosystemOnly);
self.cached_tree = None; ViewAction::None
}
KeyCode::Char('0') => {
self.filter_config.reset();
self.cached_tree = None; ViewAction::None
}
_ => ViewAction::None,
}
}
}
impl View for StudioView {
fn render(&mut self, frame: &mut Frame, area: Rect, _state: &TuiState, theme: &Theme) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(3)])
.split(area);
let panel_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(self.ratio.constraints())
.split(main_chunks[0]);
self.render_browser(frame, panel_chunks[0], theme);
let editor_style = self.border_style(StudioFocus::Editor, theme);
let focus_indicator = if self.focus == StudioFocus::Editor {
"●"
} else {
" "
};
let modified_indicator = if self.editor.modified { " ◆" } else { "" };
let editor_block = Block::default()
.title(Span::styled(
format!(" {} Editor{} ", focus_indicator, modified_indicator),
editor_style,
))
.borders(Borders::ALL)
.border_style(editor_style);
let editor_inner = editor_block.inner(panel_chunks[1]);
frame.render_widget(editor_block, panel_chunks[1]);
self.editor.render_editor(frame, editor_inner, theme);
self.render_dag_panel(frame, panel_chunks[2], theme);
self.editor.render_validation(frame, main_chunks[1], theme);
if self.command_palette.visible {
let palette_area = centered_rect(60, 50, area);
CommandPalette::new(&self.command_palette).render(palette_area, frame.buffer_mut());
}
if self.which_key.is_visible() {
WhichKey::new(&self.which_key).render(area, frame.buffer_mut());
}
}
fn handle_key(&mut self, key: KeyEvent, state: &mut TuiState) -> ViewAction {
if self.command_palette.visible {
return self.handle_palette_key(key);
}
if self.which_key.is_visible() || self.which_key.is_pending() {
if let KeyCode::Char(c) = key.code {
if let Some((prefix, key_char)) = self.which_key.on_key(c) {
return self.handle_which_key_action(prefix, key_char);
}
}
if key.code == KeyCode::Esc {
self.which_key.close();
return ViewAction::None;
}
}
if self.focus != StudioFocus::Editor || self.editor.mode != EditorMode::Insert {
if let KeyCode::Char(c) = key.code {
if key.modifiers == KeyModifiers::NONE && self.which_key.on_prefix(c) {
return ViewAction::None;
}
}
}
match (key.code, key.modifiers) {
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.command_palette.open();
return ViewAction::None;
}
(KeyCode::Tab, KeyModifiers::NONE) => {
if self.focus == StudioFocus::Editor && self.editor.mode == EditorMode::Insert {
return self.editor.handle_key(key, state);
}
self.focus = self.focus.next();
return ViewAction::None;
}
(KeyCode::BackTab, _) => {
if self.focus == StudioFocus::Editor && self.editor.mode == EditorMode::Insert {
return self.editor.handle_key(key, state);
}
self.focus = self.focus.prev();
return ViewAction::None;
}
(KeyCode::Char(']'), KeyModifiers::CONTROL) => {
self.ratio = self.ratio.next();
return ViewAction::None;
}
(KeyCode::Char('T'), _) | (KeyCode::Char('t'), KeyModifiers::CONTROL) => {
return ViewAction::ToggleTheme;
}
(KeyCode::Char('2'), _) => return ViewAction::SwitchView(TuiView::Command),
(KeyCode::Char('3'), _) => return ViewAction::SwitchView(TuiView::Control),
_ => {}
}
match self.focus {
StudioFocus::Browser => self.handle_browser_key(key),
StudioFocus::Editor => self.editor.handle_key(key, state),
StudioFocus::Dag => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.editor.dag_scroll = self.editor.dag_scroll.saturating_sub(1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.editor.dag_scroll = self.editor.dag_scroll.saturating_add(1);
ViewAction::None
}
KeyCode::Char('e') | KeyCode::Char('E') => {
self.editor.toggle_dag_mode();
ViewAction::None
}
_ => ViewAction::None,
}
}
}
}
fn status_line(&self, _state: &TuiState) -> String {
let mode = match self.editor.mode {
EditorMode::Normal => "NORMAL",
EditorMode::Insert => "INSERT",
};
format!(
"Studio | ●{} | {} | {} | [Tab] Panel • [Ctrl+]] Ratio",
self.focus.title(),
self.ratio.title(),
mode
)
}
fn tick(&mut self, _state: &mut TuiState) {
self.animation_ticker.tick();
self.editor.tick();
self.editor.maybe_validate();
self.which_key.tick();
}
fn on_enter(&mut self, _state: &mut TuiState) {
let root_path = Utf8Path::from_path(&self.root_dir).unwrap_or(Utf8Path::new("."));
self.git_cache = build_git_status_cache(root_path);
self.git_cache_time = Instant::now();
let root_node = TreeNode::build_tree(root_path, Some(&self.git_cache), None);
self.cached_tree = Some(root_node.clone());
self.tree_state.update_visible_nodes(&root_node);
self.tree_state.expand(root_node.id);
self.tree_state.update_visible_nodes(&root_node);
self.tree_state.select_first_if_none();
}
fn on_leave(&mut self, _state: &mut TuiState) {
}
}
impl StudioView {
fn handle_palette_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => {
self.command_palette.close();
ViewAction::None
}
KeyCode::Enter => {
let cmd_id = self
.command_palette
.selected_command()
.map(|cmd| cmd.id.clone());
if let Some(id) = cmd_id {
let action = self.execute_palette_command(&id);
self.command_palette.close();
return action;
}
ViewAction::None
}
KeyCode::Up => {
self.command_palette.select_prev();
ViewAction::None
}
KeyCode::Down => {
self.command_palette.select_next();
ViewAction::None
}
KeyCode::Char(c) => {
self.command_palette.query.push(c);
self.command_palette.update_filter();
ViewAction::None
}
KeyCode::Backspace => {
self.command_palette.query.pop();
self.command_palette.update_filter();
ViewAction::None
}
_ => ViewAction::None,
}
}
fn execute_palette_command(&mut self, cmd_id: &str) -> ViewAction {
match cmd_id {
"studio" => ViewAction::SwitchView(TuiView::Studio),
"command" | "chat" | "home" | "monitor" => ViewAction::SwitchView(TuiView::Command),
"control" => ViewAction::SwitchView(TuiView::Control),
"run" => {
if let Some(path) = &self.editor.path {
ViewAction::RunWorkflow(path.clone())
} else {
ViewAction::None
}
}
"validate" => {
self.editor.validate();
ViewAction::None
}
"help" => ViewAction::SwitchView(TuiView::Control),
_ => ViewAction::None,
}
}
fn handle_which_key_action(&mut self, prefix: char, key: char) -> ViewAction {
match (prefix, key) {
('g', 'g') => {
self.editor.buffer.cursor_row = 0;
self.editor.buffer.cursor_col = 0;
self.editor.buffer.scroll_offset = 0;
ViewAction::None
}
('g', 'e') => {
let last_line = self.editor.buffer.lines.len().saturating_sub(1);
self.editor.buffer.cursor_row = last_line;
self.editor.buffer.cursor_col = 0;
ViewAction::None
}
('z', 'z') => {
let visible = self.editor.buffer.visible_height.get();
let target_scroll = self.editor.buffer.cursor_row.saturating_sub(visible / 2);
self.editor.buffer.scroll_offset = target_scroll;
ViewAction::None
}
('z', 't') => {
self.editor.buffer.scroll_offset = self.editor.buffer.cursor_row;
ViewAction::None
}
('z', 'b') => {
let visible = self.editor.buffer.visible_height.get();
self.editor.buffer.scroll_offset = self
.editor
.buffer
.cursor_row
.saturating_sub(visible.saturating_sub(1));
ViewAction::None
}
(' ', 'w') => {
if let Err(e) = self.editor.save_file() {
tracing::error!("Failed to save: {}", e);
}
ViewAction::None
}
(' ', 'f') => {
self.command_palette.open();
ViewAction::None
}
(' ', 'e') => {
self.focus = StudioFocus::Browser;
ViewAction::None
}
(' ', 'q') => ViewAction::Quit,
_ => ViewAction::None,
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub yaml_valid: bool,
pub schema_valid: bool,
pub warnings: Vec<String>,
pub errors: Vec<String>,
}
impl Default for ValidationResult {
fn default() -> Self {
Self {
yaml_valid: true,
schema_valid: true,
warnings: vec![],
errors: vec![],
}
}
}
#[derive(Debug)]
pub struct TextBuffer {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
scroll_offset: usize,
visible_height: Cell<usize>,
modified: bool,
selections: SelectionSet,
}
impl Clone for TextBuffer {
fn clone(&self) -> Self {
Self {
lines: self.lines.clone(),
cursor_row: self.cursor_row,
cursor_col: self.cursor_col,
scroll_offset: self.scroll_offset,
visible_height: Cell::new(self.visible_height.get()),
modified: self.modified,
selections: self.selections.clone(),
}
}
}
impl Default for TextBuffer {
fn default() -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
visible_height: Cell::new(20),
modified: false,
selections: SelectionSet::origin(),
}
}
}
impl TextBuffer {
pub fn from_content(content: &str) -> Self {
let lines: Vec<String> = if content.is_empty() {
vec![String::new()]
} else {
content.lines().map(String::from).collect()
};
Self {
lines,
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
visible_height: Cell::new(20),
modified: false, selections: SelectionSet::origin(),
}
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
pub fn clear_modified(&mut self) {
self.modified = false;
}
pub fn set_visible_height(&self, height: usize) {
if height > 0 {
self.visible_height.set(height);
}
}
pub fn content(&self) -> String {
self.lines.join("\n")
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn cursor(&self) -> (usize, usize) {
(self.cursor_row, self.cursor_col)
}
pub fn cursor_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.clamp_cursor_col();
self.adjust_scroll();
}
}
pub fn cursor_down(&mut self) {
if self.cursor_row < self.lines.len().saturating_sub(1) {
self.cursor_row += 1;
self.clamp_cursor_col();
self.adjust_scroll();
}
}
pub fn cursor_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.current_line_len();
self.adjust_scroll();
}
}
pub fn cursor_right(&mut self) {
let line_len = self.current_line_len();
if self.cursor_col < line_len {
self.cursor_col += 1;
} else if self.cursor_row < self.lines.len().saturating_sub(1) {
self.cursor_row += 1;
self.cursor_col = 0;
self.adjust_scroll();
}
}
pub fn insert_char(&mut self, c: char) {
if let Some(line) = self.lines.get_mut(self.cursor_row) {
let col = self.cursor_col.min(line.len());
line.insert(col, c);
self.cursor_col = col + 1;
self.modified = true; }
}
pub fn insert_newline(&mut self) {
if let Some(line) = self.lines.get_mut(self.cursor_row) {
let col = self.cursor_col.min(line.len());
let rest = line[col..].to_string();
line.truncate(col);
self.lines.insert(self.cursor_row + 1, rest);
self.cursor_row += 1;
self.cursor_col = 0;
self.modified = true; self.adjust_scroll();
}
}
pub fn backspace(&mut self) {
if self.cursor_col > 0 {
if let Some(line) = self.lines.get_mut(self.cursor_row) {
let col = self.cursor_col.min(line.len());
if col > 0 {
line.remove(col - 1);
self.cursor_col = col - 1;
self.modified = true; }
}
} else if self.cursor_row > 0 {
let current_line = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].len();
self.lines[self.cursor_row].push_str(¤t_line);
self.modified = true; self.adjust_scroll();
}
}
pub fn delete(&mut self) {
if let Some(line) = self.lines.get_mut(self.cursor_row) {
let col = self.cursor_col.min(line.len());
if col < line.len() {
line.remove(col);
self.modified = true; } else if self.cursor_row < self.lines.len() - 1 {
let next_line = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next_line);
self.modified = true; }
}
}
fn current_line_len(&self) -> usize {
self.lines
.get(self.cursor_row)
.map(|l| l.len())
.unwrap_or(0)
}
fn clamp_cursor_col(&mut self) {
let line_len = self.current_line_len();
self.cursor_col = self.cursor_col.min(line_len);
}
fn adjust_scroll(&mut self) {
let height = self.visible_height.get();
if self.cursor_row < self.scroll_offset {
self.scroll_offset = self.cursor_row;
}
if height > 0 && self.cursor_row >= self.scroll_offset + height {
self.scroll_offset = self.cursor_row - height + 1;
}
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn selection(&self) -> &Selection {
self.selections.primary()
}
pub fn selection_mut(&mut self) -> &mut Selection {
self.selections.primary_mut()
}
pub fn selections(&self) -> &SelectionSet {
&self.selections
}
pub fn selections_mut(&mut self) -> &mut SelectionSet {
&mut self.selections
}
pub fn has_selection(&self) -> bool {
self.selections.has_active_selection()
}
pub fn is_multi_cursor(&self) -> bool {
self.selections.is_multi_cursor()
}
pub fn cursor_count(&self) -> usize {
self.selections.cursor_count()
}
pub fn clear_selection(&mut self) {
let pos = Position::new(self.cursor_row, self.cursor_col);
self.selections.move_all_to(pos);
}
pub fn clear_additional_cursors(&mut self) {
self.selections.clear_additional();
}
pub fn start_selection(&mut self) {
let pos = Position::new(self.cursor_row, self.cursor_col);
self.selections = SelectionSet::new(pos);
}
pub fn extend_selection(&mut self) {
let pos = Position::new(self.cursor_row, self.cursor_col);
self.selections.primary_mut().extend_to(pos);
}
pub fn sync_selection_to_cursor(&mut self) {
let pos = Position::new(self.cursor_row, self.cursor_col);
self.selections.primary_mut().extend_to(pos);
}
pub fn add_cursor(&mut self, line: usize, col: usize) {
let pos = Position::new(line, col);
self.selections.add_selection(pos);
}
pub fn get_selected_text(&self) -> Option<String> {
if !self.has_selection() {
return None;
}
let selection = self.selections.primary();
let start = selection.start();
let end = selection.end();
if start.line == end.line {
let line = self.lines.get(start.line)?;
let start_col = start.col.min(line.len());
let end_col = end.col.min(line.len());
Some(line[start_col..end_col].to_string())
} else {
let mut result = String::new();
if let Some(first_line) = self.lines.get(start.line) {
let start_col = start.col.min(first_line.len());
result.push_str(&first_line[start_col..]);
}
for line_idx in (start.line + 1)..end.line {
result.push('\n');
if let Some(line) = self.lines.get(line_idx) {
result.push_str(line);
}
}
if let Some(last_line) = self.lines.get(end.line) {
result.push('\n');
let end_col = end.col.min(last_line.len());
result.push_str(&last_line[..end_col]);
}
Some(result)
}
}
pub fn delete_selection(&mut self) -> bool {
if !self.has_selection() {
return false;
}
let selection = self.selections.primary();
let start = selection.start();
let end = selection.end();
if start.line == end.line {
if let Some(line) = self.lines.get_mut(start.line) {
let start_col = start.col.min(line.len());
let end_col = end.col.min(line.len());
line.replace_range(start_col..end_col, "");
}
} else {
let first_part = self
.lines
.get(start.line)
.map(|l| l[..start.col.min(l.len())].to_string())
.unwrap_or_default();
let last_part = self
.lines
.get(end.line)
.map(|l| l[end.col.min(l.len())..].to_string())
.unwrap_or_default();
self.lines.drain(start.line..=end.line);
let merged = format!("{}{}", first_part, last_part);
self.lines.insert(start.line, merged);
}
self.cursor_row = start.line;
self.cursor_col = start.col;
self.clear_selection();
self.modified = true;
true
}
pub fn select_all(&mut self) {
let start = Position::new(0, 0);
let last_line = self.lines.len().saturating_sub(1);
let last_col = self.lines.get(last_line).map(|l| l.len()).unwrap_or(0);
let end = Position::new(last_line, last_col);
self.selections.primary_mut().select_range(start, end);
self.cursor_row = last_line;
self.cursor_col = last_col;
}
pub fn select_next_occurrence(&mut self) -> bool {
let selected = match self.get_selected_text() {
Some(text) if !text.is_empty() => text,
_ => {
return self.select_word_under_cursor();
}
};
let primary = self.selections.primary();
let search_start_line = primary.end().line;
let search_start_col = primary.end().col;
for line_idx in search_start_line..self.lines.len() {
if let Some(line) = self.lines.get(line_idx) {
let start_col = if line_idx == search_start_line {
search_start_col
} else {
0
};
if start_col < line.len() {
if let Some(found_col) = line[start_col..].find(&selected) {
let abs_col = start_col + found_col;
let new_anchor = Position::new(line_idx, abs_col);
let new_head = Position::new(line_idx, abs_col + selected.len());
let mut new_selection = Selection::new(new_anchor);
new_selection.extend_to(new_head);
self.selections.add_selection_spanning(new_anchor, new_head);
self.cursor_row = line_idx;
self.cursor_col = abs_col + selected.len();
return true;
}
}
}
}
for line_idx in 0..=search_start_line {
if let Some(line) = self.lines.get(line_idx) {
let end_col = if line_idx == search_start_line {
search_start_col.saturating_sub(selected.len())
} else {
line.len()
};
if let Some(found_col) = line[..end_col].find(&selected) {
let new_anchor = Position::new(line_idx, found_col);
let new_head = Position::new(line_idx, found_col + selected.len());
self.selections.add_selection_spanning(new_anchor, new_head);
self.cursor_row = line_idx;
self.cursor_col = found_col + selected.len();
return true;
}
}
}
false
}
fn select_word_under_cursor(&mut self) -> bool {
if let Some(line) = self.lines.get(self.cursor_row) {
if line.is_empty() || self.cursor_col >= line.len() {
return false;
}
let chars: Vec<char> = line.chars().collect();
let col = self.cursor_col.min(chars.len().saturating_sub(1));
if !chars
.get(col)
.map(|c| c.is_alphanumeric() || *c == '_')
.unwrap_or(false)
{
return false;
}
let mut word_start = col;
while word_start > 0 {
if !chars
.get(word_start - 1)
.map(|c| c.is_alphanumeric() || *c == '_')
.unwrap_or(false)
{
break;
}
word_start -= 1;
}
let mut word_end = col;
while word_end < chars.len() {
if !chars
.get(word_end)
.map(|c| c.is_alphanumeric() || *c == '_')
.unwrap_or(false)
{
break;
}
word_end += 1;
}
let start = Position::new(self.cursor_row, word_start);
let end = Position::new(self.cursor_row, word_end);
self.selections.primary_mut().select_range(start, end);
self.cursor_col = word_end;
true
} else {
false
}
}
pub fn cursor_position(&self) -> usize {
let mut pos = 0;
for (i, line) in self.lines.iter().enumerate() {
if i == self.cursor_row {
return pos + self.cursor_col.min(line.len());
}
pos += line.len() + 1; }
pos
}
pub fn set_content(&mut self, content: &str) {
*self = Self::from_content(content);
}
pub fn set_cursor_position(&mut self, pos: usize) {
let mut remaining = pos;
for (row, line) in self.lines.iter().enumerate() {
let line_len = line.len() + 1; if remaining < line_len || row == self.lines.len() - 1 {
self.cursor_row = row;
self.cursor_col = remaining.min(line.len());
self.adjust_scroll();
return;
}
remaining -= line_len;
}
self.cursor_row = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines.last().map(|l| l.len()).unwrap_or(0);
self.adjust_scroll();
}
}
const VALIDATION_DEBOUNCE_MS: u64 = 300;
pub struct YamlEditorPanel {
pub path: Option<PathBuf>,
pub buffer: TextBuffer,
pub mode: EditorMode,
pub validation: ValidationResult,
pub modified: bool,
pub dag_expanded: bool,
pub dag_scroll: u16,
validation_pending: bool,
last_edit_time: Option<Instant>,
cached_workflow: RefCell<Option<crate::ast::Workflow>>,
cached_content_hash: Cell<u64>,
pub frame: u8,
pub rain_opacity: f32,
pub rain_fading: bool,
pub matrix_effect_enabled: bool,
edit_history: EditHistory,
diagnostics: DiagnosticsEngine,
clipboard: Option<arboard::Clipboard>,
git_status: Option<GitStatus>,
}
impl YamlEditorPanel {
pub fn new() -> Self {
Self {
path: None,
buffer: TextBuffer::default(),
mode: EditorMode::Normal,
validation: ValidationResult::default(),
modified: false,
dag_expanded: false,
dag_scroll: 0,
validation_pending: false,
last_edit_time: None,
cached_workflow: RefCell::new(None),
cached_content_hash: Cell::new(0),
frame: 0,
rain_opacity: 1.0,
rain_fading: true,
matrix_effect_enabled: true,
edit_history: EditHistory::new(100),
diagnostics: DiagnosticsEngine::new(),
clipboard: arboard::Clipboard::new().ok(),
git_status: None,
}
}
pub fn tick(&mut self) {
self.frame = self.frame.wrapping_add(1);
if self.rain_fading && self.rain_opacity > 0.0 {
self.rain_opacity = (self.rain_opacity - 0.04).max(0.0); }
}
fn mark_edited(&mut self) {
self.modified = true;
self.validation_pending = true;
self.last_edit_time = Some(Instant::now());
let content = self.buffer.content();
let cursor = self.buffer.cursor_position();
self.edit_history.push(&content, cursor);
}
fn content_hash(content: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
pub fn maybe_validate(&mut self) {
if !self.validation_pending {
return;
}
if let Some(last_edit) = self.last_edit_time {
if last_edit.elapsed() >= Duration::from_millis(VALIDATION_DEBOUNCE_MS) {
self.validate();
self.validation_pending = false;
}
}
}
pub fn toggle_dag_mode(&mut self) {
self.dag_expanded = !self.dag_expanded;
}
pub fn load_file(&mut self, path: PathBuf) -> Result<(), std::io::Error> {
let content = std::fs::read_to_string(&path)?;
self.buffer = TextBuffer::from_content(&content);
self.path = Some(path.clone());
self.modified = false;
self.validate();
self.edit_history.init(&content, 0);
self.git_status = GitStatus::open(&path);
Ok(())
}
pub fn save_file(&mut self) -> Result<(), std::io::Error> {
if let Some(path) = &self.path {
let content = self.buffer.content();
atomic_write(path, content.as_bytes())?;
self.modified = false;
}
Ok(())
}
fn undo(&mut self) {
if let Some((text, cursor)) = self.edit_history.undo() {
self.buffer.set_content(&text);
self.buffer.set_cursor_position(cursor);
self.modified = true;
self.validation_pending = true;
self.last_edit_time = Some(Instant::now());
}
}
fn redo(&mut self) {
if let Some((text, cursor)) = self.edit_history.redo() {
self.buffer.set_content(&text);
self.buffer.set_cursor_position(cursor);
self.modified = true;
self.validation_pending = true;
self.last_edit_time = Some(Instant::now());
}
}
pub fn validate(&mut self) {
let content = self.buffer.content();
self.validation.errors.clear();
self.validation.warnings.clear();
match crate::serde_yaml::from_str::<serde_json::Value>(&content) {
Ok(_) => {
self.validation.yaml_valid = true;
}
Err(e) => {
self.validation.yaml_valid = false;
self.validation.schema_valid = false;
self.validation.errors = vec![format!("YAML syntax error: {}", e)];
return; }
}
match WorkflowSchemaValidator::new() {
Ok(validator) => match validator.validate_yaml(&content) {
Ok(()) => {
self.validation.schema_valid = true;
}
Err(NikaError::SchemaValidationFailed { errors }) => {
self.validation.schema_valid = false;
self.validation.errors = errors
.into_iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
}
Err(e) => {
self.validation.schema_valid = false;
self.validation.errors = vec![e.to_string()];
}
},
Err(e) => {
self.validation.schema_valid = false;
self.validation.warnings = vec![format!("Schema validator unavailable: {}", e)];
}
}
self.diagnostics.analyze(&content);
}
#[allow(dead_code)] pub fn current_line(&self) -> usize {
self.buffer.cursor().0 + 1
}
#[allow(dead_code)] pub fn current_col(&self) -> usize {
self.buffer.cursor().1 + 1
}
pub fn diagnostics(&self) -> &DiagnosticsEngine {
&self.diagnostics
}
pub fn line_has_diagnostic(&self, line: usize) -> bool {
self.diagnostics.has_diagnostics_on_line(line)
}
pub fn diagnostic_error_count(&self) -> usize {
self.diagnostics.error_count()
}
}
impl Default for YamlEditorPanel {
fn default() -> Self {
Self::new()
}
}
impl View for YamlEditorPanel {
fn render(&mut self, frame: &mut Frame, area: Rect, _state: &TuiState, theme: &Theme) {
if self.matrix_effect_enabled && self.rain_opacity > 0.05 {
let rain_area = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
MatrixRain::new()
.frame(self.frame)
.density(0.08) .opacity(self.rain_opacity)
.with_mascots(true)
.render(rain_area, frame.buffer_mut());
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(3)])
.split(area);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(chunks[0]);
self.render_editor(frame, main_chunks[0], theme);
self.render_structure(frame, main_chunks[1], theme);
self.render_validation(frame, chunks[1], theme);
}
fn handle_key(&mut self, key: KeyEvent, _state: &mut TuiState) -> ViewAction {
match self.mode {
EditorMode::Normal => self.handle_normal_mode(key),
EditorMode::Insert => self.handle_insert_mode(key),
}
}
fn status_line(&self, _state: &TuiState) -> String {
let mode = match self.mode {
EditorMode::Normal => "NORMAL",
EditorMode::Insert => "INSERT",
};
let modified = if self.modified { " ●" } else { "" };
let undo_depth = self.edit_history.undo_depth();
let redo_depth = self.edit_history.redo_depth();
let undo_redo = if undo_depth > 0 || redo_depth > 0 {
format!(" | ⤺{} ⤻{}", undo_depth, redo_depth)
} else {
String::new()
};
format!(
"{} | Ln {}, Col {}{}{}",
mode,
self.current_line(),
self.current_col(),
modified,
undo_redo
)
}
}
impl YamlEditorPanel {
fn handle_normal_mode(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Char('q') => ViewAction::SwitchView(TuiView::Studio),
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Err(e) = self.save_file() {
ViewAction::Error(format!("Save failed: {}", e))
} else {
ViewAction::None
}
}
KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.undo();
ViewAction::None
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.redo();
ViewAction::None
}
KeyCode::Char('Z')
if key
.modifiers
.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) =>
{
self.redo();
ViewAction::None
}
KeyCode::Char('s') => ViewAction::OpenControl,
KeyCode::Char('T') => ViewAction::ToggleTheme,
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
ViewAction::ToggleTheme
}
KeyCode::Char('i') => {
self.mode = EditorMode::Insert;
ViewAction::None
}
KeyCode::Char('c') => ViewAction::SwitchView(TuiView::Command),
KeyCode::Char('e') | KeyCode::Char('E') => {
self.toggle_dag_mode();
ViewAction::None
}
KeyCode::F(5) => {
if let Some(path) = &self.path {
ViewAction::RunWorkflow(path.clone())
} else {
ViewAction::Error("No file loaded".to_string())
}
}
KeyCode::Char('2') => ViewAction::SwitchView(TuiView::Command),
KeyCode::Char('3') => ViewAction::SwitchView(TuiView::Control),
KeyCode::Up | KeyCode::Char('k') => {
self.buffer.cursor_up();
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.buffer.cursor_down();
ViewAction::None
}
KeyCode::Left => {
self.buffer.cursor_left();
ViewAction::None
}
KeyCode::Right => {
self.buffer.cursor_right();
ViewAction::None
}
_ => ViewAction::None,
}
}
fn handle_insert_mode(&mut self, key: KeyEvent) -> ViewAction {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('z') => {
self.undo();
return ViewAction::None;
}
KeyCode::Char('y') => {
self.redo();
return ViewAction::None;
}
KeyCode::Char('a') => {
self.buffer.select_all();
return ViewAction::None;
}
KeyCode::Char('c') => {
if let Some(text) = self.buffer.get_selected_text() {
if let Some(ref mut clipboard) = self.clipboard {
let _ = clipboard.set_text(text);
}
}
return ViewAction::None;
}
KeyCode::Char('x') => {
if let Some(text) = self.buffer.get_selected_text() {
if let Some(ref mut clipboard) = self.clipboard {
let _ = clipboard.set_text(text);
}
self.buffer.delete_selection();
self.mark_edited();
}
return ViewAction::None;
}
KeyCode::Char('v') => {
if let Some(ref mut clipboard) = self.clipboard {
if let Ok(text) = clipboard.get_text() {
if self.buffer.has_selection() {
self.buffer.delete_selection();
}
for c in text.chars() {
if c == '\n' {
self.buffer.insert_newline();
} else if c != '\r' {
self.buffer.insert_char(c);
}
}
self.mark_edited();
}
}
return ViewAction::None;
}
KeyCode::Char('d') => {
self.buffer.select_next_occurrence();
return ViewAction::None;
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.buffer.is_multi_cursor() {
self.buffer.clear_additional_cursors();
return ViewAction::None;
}
}
_ => {}
}
}
if key
.modifiers
.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char('Z'))
{
self.redo();
return ViewAction::None;
}
let extending = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => {
if self.buffer.has_selection() {
self.buffer.clear_selection();
} else {
self.mode = EditorMode::Normal;
}
ViewAction::None
}
KeyCode::Up => {
if extending && !self.buffer.has_selection() {
self.buffer.start_selection();
}
self.buffer.cursor_up();
if extending {
self.buffer.sync_selection_to_cursor();
} else {
self.buffer.clear_selection();
}
ViewAction::None
}
KeyCode::Down => {
if extending && !self.buffer.has_selection() {
self.buffer.start_selection();
}
self.buffer.cursor_down();
if extending {
self.buffer.sync_selection_to_cursor();
} else {
self.buffer.clear_selection();
}
ViewAction::None
}
KeyCode::Left => {
if extending && !self.buffer.has_selection() {
self.buffer.start_selection();
}
self.buffer.cursor_left();
if extending {
self.buffer.sync_selection_to_cursor();
} else {
self.buffer.clear_selection();
}
ViewAction::None
}
KeyCode::Right => {
if extending && !self.buffer.has_selection() {
self.buffer.start_selection();
}
self.buffer.cursor_right();
if extending {
self.buffer.sync_selection_to_cursor();
} else {
self.buffer.clear_selection();
}
ViewAction::None
}
KeyCode::Enter => {
if self.buffer.has_selection() {
self.buffer.delete_selection();
}
self.buffer.insert_newline();
self.mark_edited();
ViewAction::None
}
KeyCode::Backspace => {
if self.buffer.has_selection() {
self.buffer.delete_selection();
self.mark_edited();
} else {
self.buffer.backspace();
self.mark_edited();
}
ViewAction::None
}
KeyCode::Delete => {
if self.buffer.has_selection() {
self.buffer.delete_selection();
self.mark_edited();
} else {
self.buffer.delete();
self.mark_edited();
}
ViewAction::None
}
KeyCode::Char(c) => {
if self.buffer.has_selection() {
self.buffer.delete_selection();
}
self.buffer.insert_char(c);
self.mark_edited();
ViewAction::None
}
KeyCode::Tab => {
if self.buffer.has_selection() {
self.buffer.delete_selection();
}
self.buffer.insert_char(' ');
self.buffer.insert_char(' ');
self.mark_edited();
ViewAction::None
}
_ => ViewAction::None,
}
}
pub(crate) fn render_editor(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let mode_indicator = match self.mode {
EditorMode::Normal => "",
EditorMode::Insert => " [INSERT]",
};
let title = if let Some(path) = &self.path {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let icon = if filename.ends_with(".nika.yaml") {
"🦋"
} else {
"📄"
};
let modified = if self.buffer.is_modified() {
" •"
} else {
""
};
format!(" {} {}{}{} ", icon, filename, modified, mode_indicator)
} else {
format!(" EDITOR{} ", mode_indicator)
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(theme.border_normal));
let inner = block.inner(area);
frame.render_widget(block, area);
let content_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1), };
let status_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
if self.path.is_none() {
let placeholder = vec![
Line::from(""),
Line::from(""),
Line::from(vec![Span::styled(
" No file selected",
Style::default().fg(theme.text_muted),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" Press [Tab] to focus Browser",
Style::default().fg(theme.text_muted),
)]),
Line::from(vec![Span::styled(
" Select a .nika.yaml file with [Enter]",
Style::default().fg(theme.text_muted),
)]),
];
let paragraph = Paragraph::new(placeholder);
frame.render_widget(paragraph, content_area);
return;
}
let visible_height = content_area.height as usize;
self.buffer.set_visible_height(visible_height);
let selection = self.buffer.selection();
let git_line_changes: Option<HashMap<usize, LineChange>> =
self.path.as_ref().and_then(|p| {
self.git_status
.as_mut()
.map(|gs| gs.line_changes(p).changes_map())
});
let lines: Vec<Line> = self
.buffer
.lines()
.iter()
.enumerate()
.skip(self.buffer.scroll_offset())
.take(visible_height)
.map(|(i, line)| {
let line_num = i + 1;
let is_cursor_line = i == self.buffer.cursor().0;
let base_style = if is_cursor_line {
Style::default().bg(theme.highlight)
} else {
Style::default()
};
let git_gutter = git_line_changes
.as_ref()
.and_then(|changes| changes.get(&i))
.map(|change| {
let (symbol, color) = match change {
LineChange::Added => ("+", theme.git_added),
LineChange::Modified => ("~", theme.git_modified),
LineChange::Deleted => ("-", theme.git_deleted),
};
Span::styled(symbol, Style::default().fg(color))
})
.unwrap_or_else(|| Span::raw(" "));
let mut spans = vec![
git_gutter,
Span::styled(
format!("{:4} ", line_num),
Style::default().fg(theme.text_muted),
),
];
let selection_range = selection.line_range(i);
if let Some((sel_start, sel_end)) = selection_range {
let sel_end_col = sel_end.unwrap_or(line.len());
let sel_start = sel_start.min(line.len());
let sel_end_col = sel_end_col.min(line.len());
if sel_start > 0 {
let before = &line[..sel_start];
spans.extend(YamlHighlight::highlight_line(before, base_style));
}
if sel_start < sel_end_col {
let selected = &line[sel_start..sel_end_col];
let selection_style = Style::default().bg(theme.selection).fg(theme.text);
spans.push(Span::styled(selected.to_string(), selection_style));
}
if sel_end.is_some() && sel_end_col < line.len() {
let after = &line[sel_end_col..];
spans.extend(YamlHighlight::highlight_line(after, base_style));
} else if sel_end.is_none() {
}
} else {
spans.extend(YamlHighlight::highlight_line(line, base_style));
}
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, content_area);
let total_lines = self.buffer.lines().len();
if total_lines > visible_height {
let scrollbar_area = Rect {
x: content_area.x + content_area.width.saturating_sub(1),
y: content_area.y,
width: 1,
height: content_area.height,
};
let scroll_indicator = ScrollIndicator::new()
.position(self.buffer.scroll_offset(), total_lines, visible_height)
.thumb_style(Style::default().fg(theme.scrollbar_thumb))
.track_style(Style::default().fg(theme.scrollbar_track))
.show_arrows(true);
frame.render_widget(scroll_indicator, scrollbar_area);
}
let (cursor_row, cursor_col) = self.buffer.cursor();
let mode_str = match self.mode {
EditorMode::Normal => "NORMAL",
EditorMode::Insert => "INSERT",
};
let cursor_count = self.buffer.cursor_count();
let status_left = if cursor_count > 1 {
format!(
" Ln {}, Col {} ({} cursors) ",
cursor_row + 1,
cursor_col + 1,
cursor_count
)
} else {
format!(" Ln {}, Col {} ", cursor_row + 1, cursor_col + 1)
};
let status_right = format!(" {} | nika/workflow ", mode_str);
let padding = status_area.width as usize - status_left.len() - status_right.len();
let padding_str = " ".repeat(padding.max(0));
let status_line = Line::from(vec![
Span::styled(status_left, Style::default().fg(theme.text_muted)),
Span::raw(padding_str),
Span::styled(status_right, Style::default().fg(theme.text_muted)),
]);
let status_paragraph = Paragraph::new(status_line)
.style(Style::default().bg(theme.highlight).fg(theme.text_primary));
frame.render_widget(status_paragraph, status_area);
}
pub(crate) fn render_structure(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let title = if self.dag_expanded {
" STRUCTURE [E]xpanded "
} else {
" STRUCTURE [E]->expand "
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(theme.border_normal));
let inner = block.inner(area);
frame.render_widget(block, area);
let content = self.buffer.content();
self.render_dag_structure(frame, inner, &content, theme);
}
pub(crate) fn render_dag_structure(
&self,
frame: &mut Frame,
area: Rect,
yaml: &str,
theme: &Theme,
) {
let current_hash = Self::content_hash(yaml);
let cached_hash = self.cached_content_hash.get();
if current_hash != cached_hash {
*self.cached_workflow.borrow_mut() = parse_workflow(yaml).ok();
self.cached_content_hash.set(current_hash);
}
let cached = self.cached_workflow.borrow();
match cached.as_ref() {
Some(wf) => {
if wf.tasks.is_empty() {
let paragraph =
Paragraph::new("(no tasks)").style(Style::default().fg(theme.text_muted));
frame.render_widget(paragraph, area);
return;
}
let deps = self.extract_flow_dependencies(wf);
let nodes: Vec<NodeBoxData> = wf
.tasks
.iter()
.map(|task| {
let verb = self.task_verb_color(task.as_ref());
NodeBoxData::new(&task.id, verb).with_status(TaskStatus::Pending)
})
.collect();
let mode = if self.dag_expanded {
NodeBoxMode::Expanded
} else {
NodeBoxMode::Minimal
};
let widget = DagAscii::new(&nodes)
.with_dependencies(deps)
.mode(mode)
.scroll(0, self.dag_scroll);
let buf = frame.buffer_mut();
widget.render(area, buf);
}
None => {
let paragraph =
Paragraph::new("⚠ Invalid workflow\n\nFix YAML errors to\nsee task structure")
.style(Style::default().fg(theme.status_failed));
frame.render_widget(paragraph, area);
}
}
}
fn extract_flow_dependencies(&self, wf: &Workflow) -> HashMap<String, Vec<String>> {
let mut deps: HashMap<String, Vec<String>> = HashMap::new();
for task in &wf.tasks {
if let Some(ref task_deps) = task.depends_on {
let entry = deps.entry(task.id.clone()).or_default();
for source in task_deps {
if !entry.contains(source) {
entry.push(source.clone());
}
}
}
}
deps
}
fn task_verb_color(&self, task: &crate::ast::Task) -> VerbColor {
use crate::ast::TaskAction;
match &task.action {
TaskAction::Infer { .. } => VerbColor::Infer,
TaskAction::Exec { .. } => VerbColor::Exec,
TaskAction::Fetch { .. } => VerbColor::Fetch,
TaskAction::Invoke { .. } => VerbColor::Invoke,
TaskAction::Agent { .. } => VerbColor::Agent,
}
}
pub(crate) fn render_validation(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let yaml_status = if self.validation.yaml_valid {
Span::styled("Valid YAML", Style::default().fg(theme.status_success))
} else {
Span::styled("Invalid YAML", Style::default().fg(theme.status_failed))
};
let schema_status = if self.validation.schema_valid {
Span::styled("Schema OK", Style::default().fg(theme.status_success))
} else {
Span::styled("Schema Error", Style::default().fg(theme.status_failed))
};
let warning_count = self.validation.warnings.len();
let warning_status = if warning_count > 0 {
Span::styled(
format!("{} warning(s)", warning_count),
Style::default().fg(theme.status_running), )
} else {
Span::styled("No warnings", Style::default().fg(theme.status_success))
};
let complexity_meter = self.render_complexity_meter(theme);
let line = Line::from(vec![
Span::raw(" "),
yaml_status,
Span::raw(" | "),
schema_status,
Span::raw(" | "),
warning_status,
Span::raw(" | "),
complexity_meter,
]);
let paragraph = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_normal)),
);
frame.render_widget(paragraph, area);
}
fn render_complexity_meter(&self, theme: &Theme) -> Span<'static> {
let cached = self.cached_workflow.borrow();
match cached.as_ref() {
Some(wf) => {
let task_count = wf.tasks.len();
let flow_count = wf.flow_count();
let score = task_count + flow_count;
let (meter, color) = match score {
0 => ("▁", theme.text_muted),
1..=2 => ("▂", theme.status_success),
3..=5 => ("▃", theme.status_success),
6..=8 => ("▄", theme.highlight),
9..=12 => ("▅", theme.highlight),
13..=16 => ("▆", theme.status_running),
17..=20 => ("▇", theme.status_running),
_ => ("█", theme.status_failed),
};
Span::styled(
format!("DAG {} {}t {}e", meter, task_count, flow_count),
Style::default().fg(color),
)
}
None => Span::styled("DAG ▁ --", Style::default().fg(theme.text_muted)),
}
}
}
struct YamlHighlight;
impl YamlHighlight {
const KEY: Color = Color::Rgb(38, 139, 210);
const STRING: Color = Color::Rgb(133, 153, 0);
const NUMBER: Color = Color::Rgb(203, 75, 22);
const BOOL: Color = Color::Rgb(108, 113, 196);
const COMMENT: Color = Color::Rgb(88, 110, 117);
#[allow(dead_code)]
const DEFAULT: Color = Color::Reset;
const VERB: Color = Color::Rgb(211, 54, 130);
const NIKA_KEYWORD: Color = Color::Rgb(181, 137, 0);
fn highlight_line(line: &str, base_style: Style) -> Vec<Span<'static>> {
let line_owned = line.to_string();
let trimmed = line_owned.trim();
if trimmed.is_empty() {
return vec![Span::styled(line_owned, base_style)];
}
if trimmed.starts_with('#') {
return vec![Span::styled(line_owned, base_style.fg(Self::COMMENT))];
}
if let Some(colon_pos) = line.find(':') {
let (key_part, rest) = line.split_at(colon_pos);
let key_trimmed = key_part.trim();
let key_color = if matches!(
key_trimmed,
"infer" | "exec" | "fetch" | "invoke" | "agent" | "decompose"
) {
Self::VERB } else if matches!(
key_trimmed,
"schema"
| "workflow"
| "tasks"
| "flows"
| "mcp"
| "servers"
| "with"
| "params"
| "context"
| "include"
| "skills"
| "for_each"
| "id"
| "model"
| "provider"
) {
Self::NIKA_KEYWORD } else {
Self::KEY
};
let mut spans = vec![Span::styled(
format!("{}:", key_part),
base_style.fg(key_color),
)];
let value = &rest[1..]; if !value.is_empty() {
spans.push(Self::highlight_value(value, base_style));
}
return spans;
}
if trimmed.starts_with('-') {
if let Some(dash_pos) = line.find('-') {
let before_dash = &line[..dash_pos];
let after_dash = &line[dash_pos + 1..];
let mut spans = vec![
Span::styled(before_dash.to_string(), base_style),
Span::styled("-".to_string(), base_style.fg(Self::KEY)),
];
if !after_dash.is_empty() {
spans.push(Self::highlight_value(after_dash, base_style));
}
return spans;
}
}
vec![Span::styled(line_owned, base_style)]
}
fn highlight_value(value: &str, base_style: Style) -> Span<'static> {
let trimmed = value.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return Span::styled(value.to_string(), base_style.fg(Self::STRING));
}
if matches!(trimmed, "true" | "false" | "yes" | "no" | "True" | "False") {
return Span::styled(value.to_string(), base_style.fg(Self::BOOL));
}
if trimmed.parse::<f64>().is_ok() || trimmed.parse::<i64>().is_ok() {
return Span::styled(value.to_string(), base_style.fg(Self::NUMBER));
}
if value.contains(" #") {
return Span::styled(value.to_string(), base_style);
}
Span::styled(value.to_string(), base_style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_yaml_editor_panel_schema_validation_valid_workflow() {
let mut view = YamlEditorPanel::new();
let valid_yaml = r#"schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Hello world""#;
view.buffer = TextBuffer::from_content(valid_yaml);
view.validate();
assert!(view.validation.yaml_valid, "YAML should be valid");
assert!(view.validation.schema_valid, "Schema should be valid");
assert!(view.validation.errors.is_empty(), "No errors expected");
}
#[test]
fn test_yaml_editor_panel_schema_validation_invalid_schema() {
let mut view = YamlEditorPanel::new();
let invalid_yaml = r#"schema: "nika/workflow@0.12"
unknown_field: "should fail""#;
view.buffer = TextBuffer::from_content(invalid_yaml);
view.validate();
assert!(view.validation.yaml_valid, "YAML syntax is valid");
assert!(!view.validation.schema_valid, "Schema should be invalid");
assert!(!view.validation.errors.is_empty(), "Should have errors");
}
#[test]
fn test_yaml_editor_panel_schema_validation_missing_schema_field() {
let mut view = YamlEditorPanel::new();
let yaml = r#"tasks:
- id: step1
infer: "Hello""#;
view.buffer = TextBuffer::from_content(yaml);
view.validate();
assert!(view.validation.yaml_valid, "YAML syntax is valid");
assert!(!view.validation.schema_valid, "Schema should be invalid");
assert!(
view.validation.errors.iter().any(|e| e.contains("schema")),
"Error should mention 'schema'"
);
}
#[test]
fn test_text_buffer_default() {
let buffer = TextBuffer::default();
assert_eq!(buffer.lines().len(), 1);
assert_eq!(buffer.lines()[0], "");
assert_eq!(buffer.cursor(), (0, 0));
}
#[test]
fn test_text_buffer_from_content() {
let buffer = TextBuffer::from_content("line1\nline2\nline3");
assert_eq!(buffer.lines().len(), 3);
assert_eq!(buffer.lines()[0], "line1");
assert_eq!(buffer.lines()[1], "line2");
assert_eq!(buffer.lines()[2], "line3");
}
#[test]
fn test_text_buffer_from_empty_content() {
let buffer = TextBuffer::from_content("");
assert_eq!(buffer.lines().len(), 1);
assert_eq!(buffer.lines()[0], "");
}
#[test]
fn test_text_buffer_content() {
let buffer = TextBuffer::from_content("a\nb\nc");
assert_eq!(buffer.content(), "a\nb\nc");
}
#[test]
fn test_text_buffer_cursor_movement() {
let mut buffer = TextBuffer::from_content("abc\ndef\nghi");
buffer.cursor_down();
assert_eq!(buffer.cursor(), (1, 0));
buffer.cursor_right();
buffer.cursor_right();
assert_eq!(buffer.cursor(), (1, 2));
buffer.cursor_up();
assert_eq!(buffer.cursor(), (0, 2));
buffer.cursor_left();
assert_eq!(buffer.cursor(), (0, 1));
}
#[test]
fn test_text_buffer_cursor_boundary() {
let mut buffer = TextBuffer::from_content("ab\ncd");
buffer.cursor_up();
assert_eq!(buffer.cursor(), (0, 0));
buffer.cursor_down();
buffer.cursor_down(); assert_eq!(buffer.cursor(), (1, 0));
}
#[test]
fn test_text_buffer_insert_char() {
let mut buffer = TextBuffer::default();
buffer.insert_char('a');
buffer.insert_char('b');
buffer.insert_char('c');
assert_eq!(buffer.lines()[0], "abc");
assert_eq!(buffer.cursor(), (0, 3));
}
#[test]
fn test_text_buffer_insert_newline() {
let mut buffer = TextBuffer::from_content("abc");
buffer.cursor_right();
buffer.cursor_right(); buffer.insert_newline();
assert_eq!(buffer.lines().len(), 2);
assert_eq!(buffer.lines()[0], "ab");
assert_eq!(buffer.lines()[1], "c");
assert_eq!(buffer.cursor(), (1, 0));
}
#[test]
fn test_text_buffer_backspace() {
let mut buffer = TextBuffer::from_content("abc");
buffer.cursor_right();
buffer.cursor_right();
buffer.cursor_right();
buffer.backspace();
assert_eq!(buffer.lines()[0], "ab");
assert_eq!(buffer.cursor(), (0, 2));
}
#[test]
fn test_text_buffer_backspace_merge_lines() {
let mut buffer = TextBuffer::from_content("ab\ncd");
buffer.cursor_down();
buffer.backspace(); assert_eq!(buffer.lines().len(), 1);
assert_eq!(buffer.lines()[0], "abcd");
assert_eq!(buffer.cursor(), (0, 2));
}
#[test]
fn test_text_buffer_delete() {
let mut buffer = TextBuffer::from_content("abc");
buffer.cursor_right();
buffer.delete();
assert_eq!(buffer.lines()[0], "ac");
}
#[test]
fn test_yaml_editor_panel_new() {
let view = YamlEditorPanel::new();
assert_eq!(view.mode, EditorMode::Normal);
assert!(!view.modified);
assert!(view.path.is_none());
}
#[test]
fn test_yaml_editor_panel_mode_switch() {
let mut view = YamlEditorPanel::new();
assert_eq!(view.mode, EditorMode::Normal);
view.mode = EditorMode::Insert;
assert_eq!(view.mode, EditorMode::Insert);
}
#[test]
fn test_yaml_editor_panel_validation_valid_yaml_syntax() {
let mut view = YamlEditorPanel::new();
view.buffer = TextBuffer::from_content("key: value");
view.validate();
assert!(view.validation.yaml_valid, "YAML syntax should be valid");
assert!(
!view.validation.schema_valid,
"Schema should be invalid for non-workflow YAML"
);
}
#[test]
fn test_yaml_editor_panel_validation_invalid_yaml() {
let mut view = YamlEditorPanel::new();
view.buffer = TextBuffer::from_content("key: [unclosed");
view.validate();
assert!(!view.validation.yaml_valid);
assert!(!view.validation.errors.is_empty());
}
#[test]
fn test_yaml_editor_panel_cursor_position() {
let view = YamlEditorPanel::new();
assert_eq!(view.current_line(), 1);
assert_eq!(view.current_col(), 1);
}
#[test]
fn test_yaml_editor_panel_default_validation_result() {
let result = ValidationResult::default();
assert!(result.yaml_valid);
assert!(result.schema_valid);
assert!(result.warnings.is_empty());
assert!(result.errors.is_empty());
}
#[test]
fn test_yaml_editor_panel_status_line_normal_mode() {
let view = YamlEditorPanel::new();
let state = TuiState::new("test.nika.yaml");
let status = view.status_line(&state);
assert!(status.contains("NORMAL"));
assert!(status.contains("Ln 1"));
assert!(status.contains("Col 1"));
}
#[test]
fn test_yaml_editor_panel_status_line_insert_mode() {
let mut view = YamlEditorPanel::new();
view.mode = EditorMode::Insert;
let state = TuiState::new("test.nika.yaml");
let status = view.status_line(&state);
assert!(status.contains("INSERT"));
}
#[test]
fn test_yaml_editor_panel_status_line_modified() {
let mut view = YamlEditorPanel::new();
view.modified = true;
let state = TuiState::new("test.nika.yaml");
let status = view.status_line(&state);
assert!(status.contains("●"));
}
#[test]
fn test_editor_mode_default() {
let mode = EditorMode::default();
assert_eq!(mode, EditorMode::Normal);
}
#[test]
fn test_yaml_editor_panel_handle_normal_mode_quit() {
let mut view = YamlEditorPanel::new();
let mut state = TuiState::new("test.nika.yaml");
let key = KeyEvent::from(KeyCode::Char('q'));
let action = view.handle_key(key, &mut state);
match action {
ViewAction::SwitchView(TuiView::Studio) => {}
_ => panic!("Expected SwitchView(Studio)"),
}
}
#[test]
fn test_yaml_editor_panel_handle_normal_mode_insert() {
let mut view = YamlEditorPanel::new();
let mut state = TuiState::new("test.nika.yaml");
let key = KeyEvent::from(KeyCode::Char('i'));
let _ = view.handle_key(key, &mut state);
assert_eq!(view.mode, EditorMode::Insert);
}
#[test]
fn test_yaml_editor_panel_handle_insert_mode_escape() {
let mut view = YamlEditorPanel::new();
view.mode = EditorMode::Insert;
let mut state = TuiState::new("test.nika.yaml");
let key = KeyEvent::from(KeyCode::Esc);
let _ = view.handle_key(key, &mut state);
assert_eq!(view.mode, EditorMode::Normal);
}
#[test]
fn test_yaml_editor_panel_handle_insert_mode_typing() {
let mut view = YamlEditorPanel::new();
view.mode = EditorMode::Insert;
let mut state = TuiState::new("test.nika.yaml");
view.handle_key(KeyEvent::from(KeyCode::Char('a')), &mut state);
view.handle_key(KeyEvent::from(KeyCode::Char('b')), &mut state);
view.handle_key(KeyEvent::from(KeyCode::Char('c')), &mut state);
assert_eq!(view.buffer.lines()[0], "abc");
assert!(view.modified);
}
#[test]
fn test_yaml_highlight_comment() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("# This is a comment", base);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].style.fg, Some(YamlHighlight::COMMENT));
}
#[test]
fn test_yaml_highlight_key_value() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("name: my-workflow", base);
assert!(spans.len() >= 2, "Should have key and value spans");
assert!(spans[0].content.contains("name:"));
}
#[test]
fn test_yaml_highlight_verb() {
let base = Style::default();
let spans = YamlHighlight::highlight_line(" infer: \"prompt\"", base);
assert!(spans[0].content.contains("infer:"));
assert_eq!(spans[0].style.fg, Some(YamlHighlight::VERB));
}
#[test]
fn test_yaml_highlight_boolean() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("enabled: true", base);
assert!(spans.len() >= 2);
let value_span = &spans[1];
assert_eq!(value_span.style.fg, Some(YamlHighlight::BOOL));
}
#[test]
fn test_yaml_highlight_number() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("port: 8080", base);
assert!(spans.len() >= 2);
let value_span = &spans[1];
assert_eq!(value_span.style.fg, Some(YamlHighlight::NUMBER));
}
#[test]
fn test_yaml_highlight_string() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("prompt: \"Hello world\"", base);
assert!(spans.len() >= 2);
let value_span = &spans[1];
assert_eq!(value_span.style.fg, Some(YamlHighlight::STRING));
}
#[test]
fn test_yaml_highlight_list_item() {
let base = Style::default();
let spans = YamlHighlight::highlight_line(" - item", base);
assert!(
spans.len() >= 2,
"Should have indent, dash, and value spans"
);
assert!(spans.iter().any(|s| s.content.contains("-")));
}
#[test]
fn test_yaml_highlight_empty_line() {
let base = Style::default();
let spans = YamlHighlight::highlight_line("", base);
assert_eq!(spans.len(), 1);
assert!(spans[0].content.is_empty());
}
#[test]
fn test_studio_view_on_enter_initializes_tree_state() {
use crate::tui::state::TuiState;
use crate::tui::views::View;
let mut studio = StudioView::new();
let mut state = TuiState::new("test.yaml");
assert!(
studio.tree_state.visible_nodes().is_empty(),
"visible_nodes should be empty before on_enter"
);
assert!(
studio.tree_state.selected().is_none(),
"selection should be None before on_enter"
);
studio.on_enter(&mut state);
assert!(
!studio.tree_state.visible_nodes().is_empty(),
"visible_nodes should NOT be empty after on_enter"
);
assert!(
studio.tree_state.selected().is_some(),
"selection should be Some after on_enter"
);
assert!(
studio.cached_tree.is_some(),
"cached_tree should be Some after on_enter"
);
}
#[test]
fn test_studio_view_on_enter_expands_root() {
use crate::tui::state::TuiState;
use crate::tui::views::View;
let mut studio = StudioView::new();
let mut state = TuiState::new("test.yaml");
studio.on_enter(&mut state);
if let Some(ref tree) = studio.cached_tree {
assert!(
studio.tree_state.is_expanded(tree.id),
"Root directory should be expanded after on_enter"
);
} else {
panic!("cached_tree should be Some after on_enter");
}
}
#[test]
fn test_studio_view_tab_cycles_panels_in_normal_mode() {
use crate::tui::state::TuiState;
use crate::tui::views::View;
let mut studio = StudioView::new();
let mut state = TuiState::new("test.yaml");
studio.focus = StudioFocus::Browser;
studio.editor.mode = EditorMode::Normal;
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
studio.handle_key(key, &mut state);
assert_eq!(
studio.focus,
StudioFocus::Editor,
"Tab should cycle from Browser to Editor"
);
studio.handle_key(key, &mut state);
assert_eq!(
studio.focus,
StudioFocus::Dag,
"Tab should cycle from Editor to Dag when in Normal mode"
);
}
#[test]
fn test_studio_view_tab_indents_in_insert_mode() {
use crate::tui::state::TuiState;
use crate::tui::views::View;
let mut studio = StudioView::new();
let mut state = TuiState::new("test.yaml");
studio.focus = StudioFocus::Editor;
studio.editor.mode = EditorMode::Insert;
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
studio.handle_key(key, &mut state);
assert_eq!(
studio.focus,
StudioFocus::Editor,
"Tab should NOT cycle panels when editor is in Insert mode"
);
}
#[test]
fn test_text_buffer_selection_single_line() {
let mut buffer = TextBuffer::from_content("hello world");
buffer.start_selection();
buffer.cursor_right();
buffer.cursor_right();
buffer.cursor_right();
buffer.cursor_right();
buffer.cursor_right();
buffer.cursor_right(); buffer.sync_selection_to_cursor();
assert!(buffer.has_selection());
assert_eq!(buffer.get_selected_text(), Some("hello ".to_string()));
}
#[test]
fn test_text_buffer_selection_clear() {
let mut buffer = TextBuffer::from_content("hello");
buffer.start_selection();
buffer.cursor_right();
buffer.cursor_right();
buffer.sync_selection_to_cursor();
assert!(buffer.has_selection());
buffer.clear_selection();
assert!(!buffer.has_selection());
}
#[test]
fn test_text_buffer_delete_selection() {
let mut buffer = TextBuffer::from_content("hello world");
buffer.start_selection();
for _ in 0..6 {
buffer.cursor_right();
}
buffer.sync_selection_to_cursor();
assert!(buffer.delete_selection());
assert_eq!(buffer.content(), "world");
assert!(!buffer.has_selection());
}
#[test]
fn test_text_buffer_select_all() {
let mut buffer = TextBuffer::from_content("line1\nline2\nline3");
buffer.select_all();
assert!(buffer.has_selection());
assert_eq!(
buffer.get_selected_text(),
Some("line1\nline2\nline3".to_string())
);
}
#[test]
fn test_text_buffer_delete_multiline_selection() {
let mut buffer = TextBuffer::from_content("aaa\nbbb\nccc");
buffer.cursor_right();
buffer.start_selection();
buffer.cursor_down();
buffer.cursor_down();
buffer.sync_selection_to_cursor();
assert!(buffer.has_selection());
buffer.delete_selection();
assert_eq!(buffer.content(), "acc");
}
#[test]
fn test_multi_cursor_add_cursor() {
let mut buffer = TextBuffer::from_content("line1\nline2\nline3");
assert_eq!(buffer.cursor_count(), 1);
assert!(!buffer.is_multi_cursor());
buffer.add_cursor(1, 0);
assert_eq!(buffer.cursor_count(), 2);
assert!(buffer.is_multi_cursor());
buffer.add_cursor(2, 0);
assert_eq!(buffer.cursor_count(), 3);
}
#[test]
fn test_multi_cursor_clear_additional() {
let mut buffer = TextBuffer::from_content("hello\nworld");
buffer.add_cursor(1, 0);
buffer.add_cursor(1, 2);
assert_eq!(buffer.cursor_count(), 3);
buffer.clear_additional_cursors();
assert_eq!(buffer.cursor_count(), 1);
assert!(!buffer.is_multi_cursor());
}
#[test]
fn test_select_word_under_cursor() {
let mut buffer = TextBuffer::from_content("hello world test");
for _ in 0..7 {
buffer.cursor_right();
}
let selected = buffer.select_next_occurrence();
assert!(selected);
assert!(buffer.has_selection());
assert_eq!(buffer.get_selected_text(), Some("world".to_string()));
}
#[test]
fn test_select_next_occurrence() {
let mut buffer = TextBuffer::from_content("foo bar foo baz foo");
buffer.start_selection();
for _ in 0..3 {
buffer.cursor_right();
}
buffer.sync_selection_to_cursor();
assert_eq!(buffer.get_selected_text(), Some("foo".to_string()));
assert_eq!(buffer.cursor_count(), 1);
let found = buffer.select_next_occurrence();
assert!(found);
assert_eq!(buffer.cursor_count(), 2);
}
#[test]
fn test_select_next_occurrence_wraps() {
let mut buffer = TextBuffer::from_content("ab cd ab");
for _ in 0..6 {
buffer.cursor_right();
}
buffer.start_selection();
buffer.cursor_right();
buffer.cursor_right();
buffer.sync_selection_to_cursor();
assert_eq!(buffer.get_selected_text(), Some("ab".to_string()));
let found = buffer.select_next_occurrence();
assert!(found);
assert_eq!(buffer.cursor_count(), 2);
}
#[test]
fn test_select_word_boundary() {
let mut buffer = TextBuffer::from_content("hello_world test_case");
let selected = buffer.select_next_occurrence();
assert!(selected);
assert_eq!(buffer.get_selected_text(), Some("hello_world".to_string()));
}
}