diff --git a/src/args.rs b/src/args.rs index 0128c95..9f457d4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -92,6 +92,13 @@ pub fn print_detailed_help() { \x1b[1mCommands\x1b[0m: /load : Load additional point cloud file /clear: Remove all loaded points from the visualization + /export [filename] [--color|--html]: Export current view + - /export: Export plain text (no colors) with auto-generated filename + - /export filename.txt: Export plain text to specific file + - /export --color: Export with ANSI colors with auto-generated filename + - /export --html: Export as self-contained HTML with auto-generated filename + - /export filename.txt --color: Export with colors to specific file + - /export filename.html --html: Export as HTML to specific file "; print!("{}", HELP_MSG); diff --git a/src/graphics.rs b/src/graphics.rs index 08c84a8..2d42d39 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -2,6 +2,14 @@ use crossterm::{cursor, execute, style, terminal}; use std::ops; use std::*; +// Export format enum +#[derive(Clone, Copy, Debug)] +pub enum ExportFormat { + Plain, // No colors, just Braille characters + Color, // ANSI color codes (current behavior) + Html, // Self-contained HTML with inline styles +} + // Color definitions for ANSI 8-color support #[derive(Copy, Clone, Debug, PartialEq)] pub enum Color { @@ -382,6 +390,407 @@ impl Screen { // Output everything at once instead of many small writes execute!(io::stdout(), style::Print(output)).unwrap(); } + + pub fn export_to_string(&self, format: ExportFormat) -> String { + match format { + ExportFormat::Plain => self.export_plain(), + ExportFormat::Color => self.export_color(), + ExportFormat::Html => self.export_html(), + } + } + + fn export_plain(&self) -> String { + // Similar to color export but strip all color information + let chunked_rows = self.content.chunks(4); + + let mut rows_output = Vec::new(); + + for subrows in chunked_rows { + let real_row_width = self.width.div_ceil(2) as usize; + let mut real_row = vec![BraillePixel::new(); real_row_width]; + + for (subpixel_y, subrow) in subrows.iter().enumerate() { + let chunked_subrow = subrow.chunks_exact(2); + let remainder = chunked_subrow.remainder(); + + for (real_x, pixel_row) in chunked_subrow.enumerate() { + if real_x < real_row_width { + real_row[real_x][subpixel_y][..pixel_row.len()].copy_from_slice(pixel_row); + } + } + + // Handle remainder + if real_row_width > 0 && !remainder.is_empty() { + real_row[real_row_width - 1][subpixel_y][..remainder.len()] + .copy_from_slice(remainder); + } + } + + // Build row string without any color codes + let mut row_string = String::new(); + for pixel in real_row.iter() { + row_string.push(pixel.to_char()); + } + rows_output.push(row_string); + } + + // Trim the output with 2-character margin + self.trim_output_with_margin(rows_output, 2) + } + + fn export_color(&self) -> String { + // Export the content with ANSI color codes (color-specific export) + let chunked_rows = self.content.chunks(4); + let chunked_color_rows = self.colors.chunks(4); + + let mut rows_output = Vec::new(); + let mut current_color = Color::Default; + + for (subrows, color_subrows) in chunked_rows.zip(chunked_color_rows) { + let real_row_width = self.width.div_ceil(2) as usize; + let mut real_row = vec![BraillePixel::new(); real_row_width]; + let mut real_row_colors = vec![Color::Default; real_row_width]; + + for (subpixel_y, (subrow, color_subrow)) in + subrows.iter().zip(color_subrows.iter()).enumerate() + { + let chunked_subrow = subrow.chunks_exact(2); + let remainder = chunked_subrow.remainder(); + + let chunked_color_subrow = color_subrow.chunks_exact(2); + let color_remainder = chunked_color_subrow.remainder(); + + for (real_x, (pixel_row, color_row)) in + chunked_subrow.zip(chunked_color_subrow).enumerate() + { + if real_x < real_row_width { + real_row[real_x][subpixel_y][..pixel_row.len()].copy_from_slice(pixel_row); + + // Determine dominant color for this Braille character section + if real_row_colors[real_x] == Color::Default { + // Find the first non-default color in this section + for (pixel_set, &color) in pixel_row.iter().zip(color_row.iter()) { + if *pixel_set && color != Color::Default { + real_row_colors[real_x] = color; + break; + } + } + } + } + } + + // Handle remainder + if real_row_width > 0 && !remainder.is_empty() { + real_row[real_row_width - 1][subpixel_y][..remainder.len()] + .copy_from_slice(remainder); + + // Handle color remainder + if !color_remainder.is_empty() + && real_row_colors[real_row_width - 1] == Color::Default + { + for (pixel_set, &color) in remainder.iter().zip(color_remainder.iter()) { + if *pixel_set && color != Color::Default { + real_row_colors[real_row_width - 1] = color; + break; + } + } + } + } + } + + // Build row string with color changes + let mut row_string = String::new(); + for (pixel, &pixel_color) in real_row.iter().zip(real_row_colors.iter()) { + // Only change color if it's different from current + if pixel_color != current_color { + let color_code = match pixel_color { + Color::Default => "\x1b[39m".to_string(), + Color::Black => "\x1b[30m".to_string(), + Color::Red => "\x1b[31m".to_string(), + Color::Green => "\x1b[32m".to_string(), + Color::Yellow => "\x1b[33m".to_string(), + Color::Blue => "\x1b[34m".to_string(), + Color::Magenta => "\x1b[35m".to_string(), + Color::Cyan => "\x1b[36m".to_string(), + Color::White => "\x1b[37m".to_string(), + }; + row_string.push_str(&color_code); + current_color = pixel_color; + } + + row_string.push(pixel.to_char()); + } + rows_output.push(row_string); + } + + // Reset color at the end if needed + if current_color != Color::Default { + if let Some(last_row) = rows_output.last_mut() { + last_row.push_str("\x1b[39m"); // Reset to default color + } + } + + // Trim the output with 2-character margin + self.trim_output_with_margin(rows_output, 2) + } + + fn export_html(&self) -> String { + // Generate HTML with inline styles for self-contained web embedding + let chunked_rows = self.content.chunks(4); + let chunked_color_rows = self.colors.chunks(4); + + let mut rows_output = Vec::new(); + let mut current_color = Color::Default; + + for (subrows, color_subrows) in chunked_rows.zip(chunked_color_rows) { + let real_row_width = self.width.div_ceil(2) as usize; + let mut real_row = vec![BraillePixel::new(); real_row_width]; + let mut real_row_colors = vec![Color::Default; real_row_width]; + + for (subpixel_y, (subrow, color_subrow)) in + subrows.iter().zip(color_subrows.iter()).enumerate() + { + let chunked_subrow = subrow.chunks_exact(2); + let remainder = chunked_subrow.remainder(); + + let chunked_color_subrow = color_subrow.chunks_exact(2); + let color_remainder = chunked_color_subrow.remainder(); + + for (real_x, (pixel_row, color_row)) in + chunked_subrow.zip(chunked_color_subrow).enumerate() + { + if real_x < real_row_width { + real_row[real_x][subpixel_y][..pixel_row.len()].copy_from_slice(pixel_row); + + // Determine dominant color for this Braille character section + if real_row_colors[real_x] == Color::Default { + // Find the first non-default color in this section + for (pixel_set, &color) in pixel_row.iter().zip(color_row.iter()) { + if *pixel_set && color != Color::Default { + real_row_colors[real_x] = color; + break; + } + } + } + } + } + + // Handle remainder + if real_row_width > 0 && !remainder.is_empty() { + real_row[real_row_width - 1][subpixel_y][..remainder.len()] + .copy_from_slice(remainder); + + // Handle color remainder + if !color_remainder.is_empty() + && real_row_colors[real_row_width - 1] == Color::Default + { + for (pixel_set, &color) in remainder.iter().zip(color_remainder.iter()) { + if *pixel_set && color != Color::Default { + real_row_colors[real_row_width - 1] = color; + break; + } + } + } + } + } + + // Build row string with HTML span tags + let mut row_string = String::new(); + let mut span_open = false; + + for (pixel, &pixel_color) in real_row.iter().zip(real_row_colors.iter()) { + // Handle color changes with HTML spans + if pixel_color != current_color { + // Close previous span if open + if span_open { + row_string.push_str(""); + span_open = false; + } + + // Open new span if color is not default + if pixel_color != Color::Default { + let color_style = match pixel_color { + Color::Black => "color: #000000", + Color::Red => "color: #ff0000", + Color::Green => "color: #00ff00", + Color::Yellow => "color: #ffff00", + Color::Blue => "color: #0000ff", + Color::Magenta => "color: #ff00ff", + Color::Cyan => "color: #00ffff", + Color::White => "color: #ffffff", + Color::Default => "", // This case won't be reached due to the outer if condition + }; + row_string.push_str(&format!("", color_style)); + span_open = true; + } + current_color = pixel_color; + } + + // Escape HTML special characters in Braille characters (though they're unlikely) + let char_str = pixel.to_char().to_string(); + let escaped = char_str + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + row_string.push_str(&escaped); + } + + // Close span at end of row if open + if span_open { + row_string.push_str(""); + } + + rows_output.push(row_string); + } + + // Trim the content first + let trimmed_content = self.trim_output_with_margin(rows_output, 2); + + // Wrap in a complete HTML structure for easy embedding + format!( + "
{}
", + trimmed_content + ) + } + + fn trim_output_with_margin(&self, rows: Vec, margin: usize) -> String { + if rows.is_empty() { + return String::new(); + } + + // Find content bounds (ignoring ANSI color codes) + let mut top_bound = None; + let mut bottom_bound = None; + let mut left_bound = None; + let mut right_bound = None; + + // Find top and bottom bounds + for (row_idx, row) in rows.iter().enumerate() { + if self.row_has_content(row) { + if top_bound.is_none() { + top_bound = Some(row_idx); + } + bottom_bound = Some(row_idx); + } + } + + // If no content found, return empty string + let (top, bottom) = match (top_bound, bottom_bound) { + (Some(t), Some(b)) => (t, b), + _ => return String::new(), + }; + + // Find left and right bounds + for row in &rows[top..=bottom] { + let (left, right) = self.find_row_content_bounds(row); + if let (Some(l), Some(r)) = (left, right) { + left_bound = Some(left_bound.map_or(l, |current: usize| current.min(l))); + right_bound = Some(right_bound.map_or(r, |current: usize| current.max(r))); + } + } + + let (left, right) = match (left_bound, right_bound) { + (Some(l), Some(r)) => (l, r), + _ => return String::new(), + }; + + // Apply margins (but don't go negative) + let final_top = top.saturating_sub(margin); + let final_bottom = (bottom + margin).min(rows.len() - 1); + let final_left = left.saturating_sub(margin); + let final_right = right + margin; + + // Extract the trimmed region + let mut result = String::new(); + for row in &rows[final_top..=final_bottom] { + let trimmed_row = self.substring_with_ansi(row, final_left, final_right); + result.push_str(&trimmed_row); + result.push('\n'); + } + + result + } + + fn row_has_content(&self, row: &str) -> bool { + // Check if row has any non-space Braille characters (ignoring ANSI codes) + let mut chars = row.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Skip ANSI escape sequence + while let Some(ch) = chars.next() { + if ch == 'm' { + break; + } + } + } else if ch != ' ' && ch != '⠀' { + // ⠀ is the empty Braille character (U+2800) + return true; + } + } + false + } + + fn find_row_content_bounds(&self, row: &str) -> (Option, Option) { + let mut char_positions = Vec::new(); + let mut chars = row.chars().peekable(); + let mut pos = 0; + + // Extract just the content characters (no ANSI codes) + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Skip ANSI escape sequence + while let Some(ch) = chars.next() { + if ch == 'm' { + break; + } + } + } else { + char_positions.push((pos, ch)); + pos += 1; + } + } + + let mut left = None; + let mut right = None; + + for (pos, ch) in char_positions { + if ch != ' ' && ch != '⠀' { + // ⠀ is the empty Braille character (U+2800) + if left.is_none() { + left = Some(pos); + } + right = Some(pos); + } + } + + (left, right) + } + + fn substring_with_ansi(&self, text: &str, start: usize, end: usize) -> String { + let mut result = String::new(); + let mut chars = text.chars().peekable(); + let mut pos = 0; + let mut in_ansi = false; + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + in_ansi = true; + result.push(ch); + } else if in_ansi { + result.push(ch); + if ch == 'm' { + in_ansi = false; + } + } else { + if pos >= start && pos <= end { + result.push(ch); + } + pos += 1; + } + } + + result + } } pub struct Camera { diff --git a/src/main.rs b/src/main.rs index 9653601..5cadce9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ struct CommandState { active: bool, buffer: String, error_message: Option, + success_message: Option, } impl CommandState { @@ -33,6 +34,7 @@ impl CommandState { active: false, buffer: String::new(), error_message: None, + success_message: None, } } @@ -40,6 +42,7 @@ impl CommandState { self.active = true; self.buffer.clear(); self.error_message = None; + self.success_message = None; } fn exit_command_mode(&mut self) { @@ -55,7 +58,7 @@ impl CommandState { self.buffer.pop(); } - fn execute_command(&mut self, point_cloud: &mut PointCloud) -> bool { + fn execute_command(&mut self, point_cloud: &mut PointCloud, camera: &Camera) -> bool { let command = self.buffer.trim(); if command.starts_with("load ") { @@ -81,6 +84,24 @@ impl CommandState { return false; } } + } else if command.starts_with("export") { + let (path, format) = self.parse_export_command(command); + match self.export_view(camera, &path, format) { + Ok(_) => { + let format_desc = match format { + graphics::ExportFormat::Plain => "plain text", + graphics::ExportFormat::Color => "colored text", + graphics::ExportFormat::Html => "HTML", + }; + self.success_message = Some(format!("Exported {} to: {}", format_desc, path)); + self.exit_command_mode(); + return false; + } + Err(e) => { + self.error_message = Some(format!("Export failed: {}", e)); + return false; + } + } } else if command == "clear" { // Clear all points from the point cloud point_cloud.points.clear(); @@ -98,13 +119,79 @@ impl CommandState { false } + fn export_view(&self, camera: &Camera, path: &str, format: graphics::ExportFormat) -> Result<(), Box> { + let content = camera.screen.export_to_string(format); + fs::write(path, content)?; + Ok(()) + } + + fn parse_export_command(&self, command: &str) -> (String, graphics::ExportFormat) { + let parts: Vec<&str> = command.split_whitespace().collect(); + + if parts.len() == 1 { + // Just "/export" - default format (plain) with timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + (format!("altostratus_export_{}.txt", timestamp), graphics::ExportFormat::Plain) + } else if parts.len() == 2 { + let arg = parts[1]; + if arg == "--color" { + // "/export --color" - color format with timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + (format!("altostratus_export_color_{}.txt", timestamp), graphics::ExportFormat::Color) + } else if arg == "--html" { + // "/export --html" - HTML format with timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + (format!("altostratus_export_{}.html", timestamp), graphics::ExportFormat::Html) + } else { + // "/export filename" - assume plain format + (arg.to_string(), graphics::ExportFormat::Plain) + } + } else if parts.len() == 3 { + let filename = parts[1]; + let flag = parts[2]; + let format = match flag { + "--color" => graphics::ExportFormat::Color, + "--html" => graphics::ExportFormat::Html, + _ => graphics::ExportFormat::Plain, + }; + (filename.to_string(), format) + } else { + // Fallback to default + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + (format!("altostratus_export_{}.txt", timestamp), graphics::ExportFormat::Plain) + } + } + fn get_display_text(&self) -> String { if let Some(ref error) = self.error_message { format!("ERROR: {} (press ESC to continue)", error) + } else if let Some(ref success) = self.success_message { + format!("SUCCESS: {} (press ESC to continue)", success) } else { format!("Command: {}_", self.buffer) } } + + fn has_message(&self) -> bool { + self.error_message.is_some() || self.success_message.is_some() + } + + fn clear_messages(&mut self) { + self.error_message = None; + self.success_message = None; + } } fn graceful_close() -> ! { @@ -216,10 +303,14 @@ fn run_application(file_paths: Vec) { // Handle command mode input match key_event.code { event::KeyCode::Esc => { - command_state.exit_command_mode(); + if command_state.has_message() { + command_state.clear_messages(); + } else { + command_state.exit_command_mode(); + } } event::KeyCode::Enter => { - command_state.execute_command(&mut point_cloud); + command_state.execute_command(&mut point_cloud, &camera); } event::KeyCode::Backspace => { command_state.backspace();