@@ -23,6 +23,50 @@ const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow; // Yellow for active li |
| 23 | 23 | const BRACKET_MATCH_BG: Color = Color::AnsiValue(240); // Highlight for matching brackets |
| 24 | 24 | // Secondary cursors use Color::Magenta for visibility |
| 25 | 25 | |
| 26 | +// Tab bar colors |
| 27 | +const TAB_BAR_BG: Color = Color::AnsiValue(235); // Slightly lighter than editor bg |
| 28 | +const TAB_ACTIVE_BG: Color = Color::AnsiValue(238); // Active tab background |
| 29 | +const TAB_INACTIVE_FG: Color = Color::AnsiValue(245); // Inactive tab text |
| 30 | +const TAB_ACTIVE_FG: Color = Color::White; // Active tab text |
| 31 | +const TAB_MODIFIED_FG: Color = Color::Yellow; // Modified indicator |
| 32 | + |
| 33 | +/// Tab information for rendering |
| 34 | +pub struct TabInfo { |
| 35 | + pub name: String, |
| 36 | + pub is_active: bool, |
| 37 | + pub is_modified: bool, |
| 38 | + pub index: usize, |
| 39 | +} |
| 40 | + |
| 41 | +/// Pane information for rendering |
| 42 | +pub struct PaneInfo<'a> { |
| 43 | + pub buffer: &'a Buffer, |
| 44 | + pub cursors: &'a Cursors, |
| 45 | + pub viewport_line: usize, |
| 46 | + pub bounds: PaneBounds, |
| 47 | + pub is_active: bool, |
| 48 | + pub bracket_match: Option<(usize, usize)>, |
| 49 | + pub is_modified: bool, |
| 50 | +} |
| 51 | + |
| 52 | +/// Normalized pane bounds (0.0 to 1.0) |
| 53 | +#[derive(Debug, Clone)] |
| 54 | +pub struct PaneBounds { |
| 55 | + pub x_start: f32, |
| 56 | + pub y_start: f32, |
| 57 | + pub x_end: f32, |
| 58 | + pub y_end: f32, |
| 59 | +} |
| 60 | + |
| 61 | +// Pane colors |
| 62 | +const PANE_SEPARATOR_FG: Color = Color::AnsiValue(240); |
| 63 | +const PANE_ACTIVE_SEPARATOR_FG: Color = Color::AnsiValue(250); |
| 64 | +// Inactive pane uses darker colors |
| 65 | +const INACTIVE_BG_COLOR: Color = Color::AnsiValue(233); // Darker than active |
| 66 | +const INACTIVE_CURRENT_LINE_BG: Color = Color::AnsiValue(234); // Dimmed current line |
| 67 | +const INACTIVE_LINE_NUM_COLOR: Color = Color::AnsiValue(240); // Dimmed line numbers |
| 68 | +const INACTIVE_TEXT_COLOR: Color = Color::AnsiValue(245); // Dimmed text |
| 69 | + |
| 26 | 70 | /// Terminal screen renderer |
| 27 | 71 | pub struct Screen { |
| 28 | 72 | stdout: Stdout, |
@@ -85,7 +129,373 @@ impl Screen { |
| 85 | 129 | Ok(()) |
| 86 | 130 | } |
| 87 | 131 | |
| 88 | | - /// Render the editor view |
| 132 | + /// Render the tab bar |
| 133 | + /// Returns the height of the tab bar (1 if rendered, 0 if only one tab) |
| 134 | + pub fn render_tab_bar(&mut self, tabs: &[TabInfo], left_offset: u16) -> Result<u16> { |
| 135 | + // Only show tab bar if there's more than one tab |
| 136 | + if tabs.len() <= 1 { |
| 137 | + return Ok(0); |
| 138 | + } |
| 139 | + |
| 140 | + execute!(self.stdout, MoveTo(left_offset, 0))?; |
| 141 | + |
| 142 | + // Fill the tab bar background |
| 143 | + let available_width = self.cols.saturating_sub(left_offset) as usize; |
| 144 | + execute!( |
| 145 | + self.stdout, |
| 146 | + SetBackgroundColor(TAB_BAR_BG), |
| 147 | + SetForegroundColor(TAB_INACTIVE_FG), |
| 148 | + )?; |
| 149 | + |
| 150 | + // Calculate max width per tab |
| 151 | + let tab_count = tabs.len(); |
| 152 | + let separators = tab_count.saturating_sub(1); |
| 153 | + let available_for_tabs = available_width.saturating_sub(separators); |
| 154 | + let max_tab_width = (available_for_tabs / tab_count).max(3); // At least 3 chars per tab |
| 155 | + |
| 156 | + let mut current_col = left_offset as usize; |
| 157 | + |
| 158 | + for (i, tab) in tabs.iter().enumerate() { |
| 159 | + // Build tab label: [index] name [*] |
| 160 | + let index_str = if tab.index < 9 { |
| 161 | + format!("{}", tab.index + 1) |
| 162 | + } else { |
| 163 | + String::new() |
| 164 | + }; |
| 165 | + |
| 166 | + let modified_str = if tab.is_modified { "*" } else { "" }; |
| 167 | + |
| 168 | + // Calculate available space for name |
| 169 | + let prefix_len = if index_str.is_empty() { 0 } else { index_str.len() + 1 }; // "1 " |
| 170 | + let suffix_len = modified_str.len(); |
| 171 | + let name_max = max_tab_width.saturating_sub(prefix_len + suffix_len); |
| 172 | + |
| 173 | + // Truncate name if needed |
| 174 | + let display_name: String = if tab.name.len() > name_max { |
| 175 | + tab.name.chars().take(name_max.saturating_sub(1)).collect::<String>() + "…" |
| 176 | + } else { |
| 177 | + tab.name.clone() |
| 178 | + }; |
| 179 | + |
| 180 | + // Set colors based on active state |
| 181 | + let (bg, fg) = if tab.is_active { |
| 182 | + (TAB_ACTIVE_BG, TAB_ACTIVE_FG) |
| 183 | + } else { |
| 184 | + (TAB_BAR_BG, TAB_INACTIVE_FG) |
| 185 | + }; |
| 186 | + |
| 187 | + execute!( |
| 188 | + self.stdout, |
| 189 | + MoveTo(current_col as u16, 0), |
| 190 | + SetBackgroundColor(bg), |
| 191 | + )?; |
| 192 | + |
| 193 | + // Print index number (for Alt+N shortcut hint) |
| 194 | + if !index_str.is_empty() { |
| 195 | + execute!( |
| 196 | + self.stdout, |
| 197 | + SetForegroundColor(LINE_NUM_COLOR), |
| 198 | + Print(&index_str), |
| 199 | + Print(" "), |
| 200 | + )?; |
| 201 | + } |
| 202 | + |
| 203 | + // Print tab name |
| 204 | + execute!( |
| 205 | + self.stdout, |
| 206 | + SetForegroundColor(fg), |
| 207 | + Print(&display_name), |
| 208 | + )?; |
| 209 | + |
| 210 | + // Print modified indicator |
| 211 | + if tab.is_modified { |
| 212 | + execute!( |
| 213 | + self.stdout, |
| 214 | + SetForegroundColor(TAB_MODIFIED_FG), |
| 215 | + Print(modified_str), |
| 216 | + )?; |
| 217 | + } |
| 218 | + |
| 219 | + current_col += prefix_len + display_name.len() + suffix_len; |
| 220 | + |
| 221 | + // Add separator between tabs |
| 222 | + if i + 1 < tab_count { |
| 223 | + execute!( |
| 224 | + self.stdout, |
| 225 | + SetBackgroundColor(TAB_BAR_BG), |
| 226 | + SetForegroundColor(LINE_NUM_COLOR), |
| 227 | + Print("│"), |
| 228 | + )?; |
| 229 | + current_col += 1; |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + // Fill the rest of the line |
| 234 | + execute!( |
| 235 | + self.stdout, |
| 236 | + SetBackgroundColor(TAB_BAR_BG), |
| 237 | + Clear(ClearType::UntilNewLine), |
| 238 | + ResetColor, |
| 239 | + )?; |
| 240 | + |
| 241 | + Ok(1) |
| 242 | + } |
| 243 | + |
| 244 | + /// Render multiple panes with their separators |
| 245 | + /// Returns the position of the hardware cursor (for the active pane) |
| 246 | + pub fn render_panes( |
| 247 | + &mut self, |
| 248 | + panes: &[PaneInfo], |
| 249 | + filename: Option<&str>, |
| 250 | + message: Option<&str>, |
| 251 | + left_offset: u16, |
| 252 | + top_offset: u16, |
| 253 | + ) -> Result<()> { |
| 254 | + execute!(self.stdout, Hide)?; |
| 255 | + |
| 256 | + // Calculate available screen area |
| 257 | + let available_width = self.cols.saturating_sub(left_offset) as f32; |
| 258 | + let available_height = self.rows.saturating_sub(1 + top_offset) as f32; // -1 for status bar |
| 259 | + |
| 260 | + // Track where to place the hardware cursor (active pane's primary cursor) |
| 261 | + let mut cursor_screen_pos: Option<(u16, u16)> = None; |
| 262 | + |
| 263 | + for pane in panes { |
| 264 | + // Convert normalized bounds to screen coordinates |
| 265 | + let pane_x = left_offset + (pane.bounds.x_start * available_width) as u16; |
| 266 | + let pane_y = top_offset + (pane.bounds.y_start * available_height) as u16; |
| 267 | + let pane_width = ((pane.bounds.x_end - pane.bounds.x_start) * available_width) as u16; |
| 268 | + let pane_height = ((pane.bounds.y_end - pane.bounds.y_start) * available_height) as u16; |
| 269 | + |
| 270 | + // Render this pane |
| 271 | + let cursor_pos = self.render_single_pane( |
| 272 | + pane, |
| 273 | + pane_x, |
| 274 | + pane_y, |
| 275 | + pane_width, |
| 276 | + pane_height, |
| 277 | + )?; |
| 278 | + |
| 279 | + // Track active pane's cursor position |
| 280 | + if pane.is_active { |
| 281 | + cursor_screen_pos = cursor_pos; |
| 282 | + } |
| 283 | + |
| 284 | + // Draw separator on the left edge if not at left boundary |
| 285 | + if pane.bounds.x_start > 0.01 { |
| 286 | + let sep_x = pane_x.saturating_sub(1); |
| 287 | + let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG }; |
| 288 | + for row in 0..pane_height { |
| 289 | + execute!( |
| 290 | + self.stdout, |
| 291 | + MoveTo(sep_x, pane_y + row), |
| 292 | + SetBackgroundColor(BG_COLOR), |
| 293 | + SetForegroundColor(sep_color), |
| 294 | + Print("│"), |
| 295 | + )?; |
| 296 | + } |
| 297 | + } |
| 298 | + |
| 299 | + // Draw separator on the top edge if not at top boundary |
| 300 | + if pane.bounds.y_start > 0.01 { |
| 301 | + let sep_y = pane_y.saturating_sub(1); |
| 302 | + let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG }; |
| 303 | + for col in 0..pane_width { |
| 304 | + execute!( |
| 305 | + self.stdout, |
| 306 | + MoveTo(pane_x + col, sep_y), |
| 307 | + SetBackgroundColor(BG_COLOR), |
| 308 | + SetForegroundColor(sep_color), |
| 309 | + Print("─"), |
| 310 | + )?; |
| 311 | + } |
| 312 | + } |
| 313 | + } |
| 314 | + |
| 315 | + // Render status bar (use active pane's info) |
| 316 | + if let Some(active_pane) = panes.iter().find(|p| p.is_active) { |
| 317 | + self.render_status_bar_with_offset( |
| 318 | + active_pane.cursors, |
| 319 | + filename, |
| 320 | + message, |
| 321 | + left_offset, |
| 322 | + active_pane.is_modified, |
| 323 | + )?; |
| 324 | + } |
| 325 | + |
| 326 | + // Position hardware cursor |
| 327 | + if let Some((col, row)) = cursor_screen_pos { |
| 328 | + execute!(self.stdout, MoveTo(col, row), Show)?; |
| 329 | + } |
| 330 | + |
| 331 | + self.stdout.flush()?; |
| 332 | + Ok(()) |
| 333 | + } |
| 334 | + |
| 335 | + /// Render a single pane within its screen bounds |
| 336 | + /// Returns the screen position of the primary cursor if this is the active pane |
| 337 | + fn render_single_pane( |
| 338 | + &mut self, |
| 339 | + pane: &PaneInfo, |
| 340 | + x: u16, |
| 341 | + y: u16, |
| 342 | + width: u16, |
| 343 | + height: u16, |
| 344 | + ) -> Result<Option<(u16, u16)>> { |
| 345 | + let buffer = pane.buffer; |
| 346 | + let cursors = pane.cursors; |
| 347 | + let is_active = pane.is_active; |
| 348 | + |
| 349 | + // Choose colors based on active state |
| 350 | + let bg_color = if is_active { BG_COLOR } else { INACTIVE_BG_COLOR }; |
| 351 | + let current_line_bg = if is_active { CURRENT_LINE_BG } else { INACTIVE_CURRENT_LINE_BG }; |
| 352 | + let line_num_color = if is_active { LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR }; |
| 353 | + let current_line_num_color = if is_active { CURRENT_LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR }; |
| 354 | + let text_color = if is_active { Color::Reset } else { INACTIVE_TEXT_COLOR }; |
| 355 | + |
| 356 | + let line_num_width = self.line_number_width(buffer.line_count()); |
| 357 | + let text_cols = (width as usize).saturating_sub(line_num_width + 1); |
| 358 | + |
| 359 | + let primary = cursors.primary(); |
| 360 | + |
| 361 | + // Collect selections and cursor positions (only show in active pane) |
| 362 | + let selections: Vec<(Position, Position)> = if is_active { |
| 363 | + cursors.all() |
| 364 | + .iter() |
| 365 | + .filter_map(|c| c.selection_bounds()) |
| 366 | + .collect() |
| 367 | + } else { |
| 368 | + Vec::new() |
| 369 | + }; |
| 370 | + |
| 371 | + let primary_idx = cursors.primary_index(); |
| 372 | + let cursor_positions: Vec<(usize, usize, bool)> = if is_active { |
| 373 | + cursors.all() |
| 374 | + .iter() |
| 375 | + .enumerate() |
| 376 | + .map(|(i, c)| (c.line, c.col, i == primary_idx)) |
| 377 | + .collect() |
| 378 | + } else { |
| 379 | + Vec::new() |
| 380 | + }; |
| 381 | + |
| 382 | + // Draw text area |
| 383 | + for row in 0..height as usize { |
| 384 | + let line_idx = pane.viewport_line + row; |
| 385 | + let is_current_line = line_idx == primary.line; |
| 386 | + execute!(self.stdout, MoveTo(x, y + row as u16))?; |
| 387 | + |
| 388 | + if line_idx < buffer.line_count() { |
| 389 | + let line_num_fg = if is_current_line { |
| 390 | + current_line_num_color |
| 391 | + } else { |
| 392 | + line_num_color |
| 393 | + }; |
| 394 | + let line_bg = if is_current_line { current_line_bg } else { bg_color }; |
| 395 | + |
| 396 | + execute!( |
| 397 | + self.stdout, |
| 398 | + SetBackgroundColor(line_bg), |
| 399 | + SetForegroundColor(line_num_fg), |
| 400 | + Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)), |
| 401 | + )?; |
| 402 | + |
| 403 | + if let Some(line) = buffer.line_str(line_idx) { |
| 404 | + if is_active { |
| 405 | + // Active pane: full highlighting |
| 406 | + let bracket_col = pane.bracket_match |
| 407 | + .filter(|(bl, _)| *bl == line_idx) |
| 408 | + .map(|(_, bc)| bc); |
| 409 | + |
| 410 | + let secondary_cursors: Vec<usize> = cursor_positions.iter() |
| 411 | + .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary) |
| 412 | + .map(|(_, c, _)| *c) |
| 413 | + .collect(); |
| 414 | + |
| 415 | + self.render_line_with_cursors_bounded( |
| 416 | + &line, |
| 417 | + line_idx, |
| 418 | + text_cols, |
| 419 | + &selections, |
| 420 | + is_current_line, |
| 421 | + bracket_col, |
| 422 | + &secondary_cursors, |
| 423 | + )?; |
| 424 | + } else { |
| 425 | + // Inactive pane: simple dimmed text |
| 426 | + let chars: String = line.chars().take(text_cols).collect(); |
| 427 | + execute!( |
| 428 | + self.stdout, |
| 429 | + SetBackgroundColor(line_bg), |
| 430 | + SetForegroundColor(text_color), |
| 431 | + Print(&chars), |
| 432 | + )?; |
| 433 | + } |
| 434 | + } |
| 435 | + |
| 436 | + // Fill rest of pane width |
| 437 | + execute!( |
| 438 | + self.stdout, |
| 439 | + SetBackgroundColor(line_bg), |
| 440 | + )?; |
| 441 | + let line_len = buffer.line_str(line_idx).map(|l| l.len()).unwrap_or(0); |
| 442 | + let current_col = x + line_num_width as u16 + 1 + text_cols.min(line_len) as u16; |
| 443 | + let remaining = (x + width).saturating_sub(current_col); |
| 444 | + if remaining > 0 { |
| 445 | + execute!(self.stdout, Print(" ".repeat(remaining as usize)))?; |
| 446 | + } |
| 447 | + execute!(self.stdout, ResetColor)?; |
| 448 | + } else { |
| 449 | + execute!( |
| 450 | + self.stdout, |
| 451 | + SetBackgroundColor(bg_color), |
| 452 | + SetForegroundColor(if is_active { Color::DarkBlue } else { INACTIVE_LINE_NUM_COLOR }), |
| 453 | + Print(format!("{:>width$} ", "~", width = line_num_width)), |
| 454 | + )?; |
| 455 | + // Fill rest of line within pane bounds |
| 456 | + let remaining = width.saturating_sub(line_num_width as u16 + 1); |
| 457 | + execute!(self.stdout, Print(" ".repeat(remaining as usize)), ResetColor)?; |
| 458 | + } |
| 459 | + } |
| 460 | + |
| 461 | + // Return cursor position if this is the active pane |
| 462 | + if pane.is_active { |
| 463 | + let cursor_row = primary.line.saturating_sub(pane.viewport_line); |
| 464 | + if cursor_row < height as usize { |
| 465 | + let cursor_screen_row = y + cursor_row as u16; |
| 466 | + let cursor_screen_col = x + line_num_width as u16 + 1 + primary.col as u16; |
| 467 | + return Ok(Some((cursor_screen_col, cursor_screen_row))); |
| 468 | + } |
| 469 | + } |
| 470 | + |
| 471 | + Ok(None) |
| 472 | + } |
| 473 | + |
| 474 | + /// Render line with cursors, bounded to a specific width |
| 475 | + fn render_line_with_cursors_bounded( |
| 476 | + &mut self, |
| 477 | + line: &str, |
| 478 | + line_idx: usize, |
| 479 | + max_cols: usize, |
| 480 | + selections: &[(Position, Position)], |
| 481 | + is_current_line: bool, |
| 482 | + bracket_col: Option<usize>, |
| 483 | + secondary_cursors: &[usize], |
| 484 | + ) -> Result<()> { |
| 485 | + // Delegate to existing method - it already handles max_cols |
| 486 | + self.render_line_with_cursors( |
| 487 | + line, |
| 488 | + line_idx, |
| 489 | + max_cols, |
| 490 | + selections, |
| 491 | + is_current_line, |
| 492 | + bracket_col, |
| 493 | + secondary_cursors, |
| 494 | + ) |
| 495 | + } |
| 496 | + |
| 497 | + /// Render the editor view (without offsets - use render_with_offset instead) |
| 498 | + #[allow(dead_code)] |
| 89 | 499 | pub fn render( |
| 90 | 500 | &mut self, |
| 91 | 501 | buffer: &Buffer, |
@@ -300,6 +710,7 @@ impl Screen { |
| 300 | 710 | Ok(()) |
| 301 | 711 | } |
| 302 | 712 | |
| 713 | + #[allow(dead_code)] |
| 303 | 714 | fn render_status_bar( |
| 304 | 715 | &mut self, |
| 305 | 716 | buffer: &Buffer, |
@@ -368,21 +779,83 @@ impl Screen { |
| 368 | 779 | scroll: usize, |
| 369 | 780 | width: u16, |
| 370 | 781 | hints_expanded: bool, |
| 782 | + repo_name: &str, |
| 783 | + branch: Option<&str>, |
| 371 | 784 | ) -> Result<()> { |
| 372 | 785 | let width = width as usize; |
| 373 | 786 | let text_rows = self.rows.saturating_sub(1) as usize; |
| 374 | 787 | let hint_rows = if hints_expanded { 4 } else { 1 }; |
| 375 | | - let tree_rows = text_rows.saturating_sub(hint_rows); |
| 788 | + let header_rows = 2; // Header line + separator |
| 789 | + let tree_rows = text_rows.saturating_sub(hint_rows + header_rows); |
| 790 | + |
| 791 | + // Draw header: repo_name:branch |
| 792 | + execute!(self.stdout, MoveTo(0, 0))?; |
| 793 | + let header_text = if let Some(b) = branch { |
| 794 | + format!("{}:{}", repo_name, b) |
| 795 | + } else { |
| 796 | + repo_name.to_string() |
| 797 | + }; |
| 798 | + let truncated: String = header_text.chars().take(width.saturating_sub(1)).collect(); |
| 799 | + let padded = format!("{:<width$}", truncated, width = width); |
| 376 | 800 | |
| 377 | | - // Draw file tree |
| 801 | + // Render header with cyan repo name, yellow branch |
| 802 | + execute!( |
| 803 | + self.stdout, |
| 804 | + SetBackgroundColor(BG_COLOR), |
| 805 | + SetForegroundColor(Color::Cyan), |
| 806 | + )?; |
| 807 | + if let Some(b) = branch { |
| 808 | + let repo_display: String = repo_name.chars().take(width.saturating_sub(1)).collect(); |
| 809 | + execute!(self.stdout, Print(&repo_display))?; |
| 810 | + execute!( |
| 811 | + self.stdout, |
| 812 | + SetForegroundColor(Color::DarkGrey), |
| 813 | + Print(":"), |
| 814 | + SetForegroundColor(Color::Yellow), |
| 815 | + )?; |
| 816 | + let remaining = width.saturating_sub(repo_display.len() + 1); |
| 817 | + let branch_display: String = b.chars().take(remaining).collect(); |
| 818 | + let branch_padded = format!("{:<width$}", branch_display, width = remaining); |
| 819 | + execute!(self.stdout, Print(&branch_padded))?; |
| 820 | + } else { |
| 821 | + execute!(self.stdout, Print(&padded))?; |
| 822 | + } |
| 823 | + execute!(self.stdout, ResetColor)?; |
| 824 | + |
| 825 | + // Draw separator |
| 826 | + execute!(self.stdout, MoveTo(0, 1))?; |
| 827 | + let separator = "─".repeat(width); |
| 828 | + execute!( |
| 829 | + self.stdout, |
| 830 | + SetBackgroundColor(BG_COLOR), |
| 831 | + SetForegroundColor(Color::DarkGrey), |
| 832 | + Print(&separator), |
| 833 | + ResetColor, |
| 834 | + )?; |
| 835 | + |
| 836 | + // Draw file tree (starting after header) |
| 378 | 837 | for row in 0..tree_rows { |
| 379 | | - execute!(self.stdout, MoveTo(0, row as u16))?; |
| 838 | + let screen_row = (row + header_rows) as u16; |
| 839 | + execute!(self.stdout, MoveTo(0, screen_row))?; |
| 380 | 840 | |
| 381 | 841 | let item_idx = scroll + row; |
| 382 | 842 | if item_idx < items.len() { |
| 383 | 843 | let item = &items[item_idx]; |
| 384 | 844 | let is_selected = item_idx == selected; |
| 385 | 845 | |
| 846 | + // Build git status indicator |
| 847 | + let git_indicator = if item.git_status.staged { |
| 848 | + " \x1b[32m↑\x1b[0m" // Green up arrow |
| 849 | + } else if item.git_status.unstaged { |
| 850 | + " \x1b[31m✗\x1b[0m" // Red X |
| 851 | + } else if item.git_status.untracked { |
| 852 | + " \x1b[90m?\x1b[0m" // Gray question mark |
| 853 | + } else if item.git_status.incoming { |
| 854 | + " \x1b[34m↓\x1b[0m" // Blue down arrow |
| 855 | + } else { |
| 856 | + "" |
| 857 | + }; |
| 858 | + |
| 386 | 859 | // Build display line |
| 387 | 860 | let indent = " ".repeat(item.depth.saturating_sub(1)); |
| 388 | 861 | let icon = if item.is_dir { |
@@ -391,23 +864,42 @@ impl Screen { |
| 391 | 864 | " " |
| 392 | 865 | }; |
| 393 | 866 | let suffix = if item.is_dir { "/" } else { "" }; |
| 394 | | - let display = format!("{}{}{}{}", indent, icon, item.name, suffix); |
| 395 | 867 | |
| 396 | | - // Truncate to width |
| 397 | | - let truncated: String = display.chars().take(width.saturating_sub(1)).collect(); |
| 398 | | - let padded = format!("{:<width$}", truncated, width = width); |
| 868 | + // Calculate space for name (leave room for git indicator) |
| 869 | + let prefix_len = indent.len() + icon.len(); |
| 870 | + let indicator_display_len = if git_indicator.is_empty() { 0 } else { 2 }; // " X" |
| 871 | + let name_max = width.saturating_sub(prefix_len + suffix.len() + indicator_display_len); |
| 872 | + let name_truncated: String = item.name.chars().take(name_max).collect(); |
| 873 | + |
| 874 | + let display_base = format!("{}{}{}{}", indent, icon, name_truncated, suffix); |
| 399 | 875 | |
| 400 | 876 | if is_selected { |
| 401 | | - // Highlight selected |
| 877 | + // Highlight selected - need to handle git indicator specially |
| 878 | + let padded_len = width.saturating_sub(indicator_display_len); |
| 879 | + let padded = format!("{:<width$}", display_base, width = padded_len); |
| 402 | 880 | execute!( |
| 403 | 881 | self.stdout, |
| 404 | 882 | SetBackgroundColor(Color::DarkGrey), |
| 405 | 883 | SetForegroundColor(Color::White), |
| 406 | 884 | Print(&padded), |
| 407 | | - ResetColor |
| 408 | 885 | )?; |
| 886 | + if !git_indicator.is_empty() { |
| 887 | + // Git indicator with selection background |
| 888 | + if item.git_status.staged { |
| 889 | + execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?; |
| 890 | + } else if item.git_status.unstaged { |
| 891 | + execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?; |
| 892 | + } else if item.git_status.untracked { |
| 893 | + execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?; |
| 894 | + } else if item.git_status.incoming { |
| 895 | + execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?; |
| 896 | + } |
| 897 | + } |
| 898 | + execute!(self.stdout, ResetColor)?; |
| 409 | 899 | } else if item.is_dir { |
| 410 | 900 | // Directories in blue |
| 901 | + let padded_len = width.saturating_sub(indicator_display_len); |
| 902 | + let padded = format!("{:<width$}", display_base, width = padded_len); |
| 411 | 903 | execute!( |
| 412 | 904 | self.stdout, |
| 413 | 905 | SetBackgroundColor(BG_COLOR), |
@@ -415,15 +907,37 @@ impl Screen { |
| 415 | 907 | Print(&padded), |
| 416 | 908 | ResetColor |
| 417 | 909 | )?; |
| 910 | + } else if item.git_status.gitignored { |
| 911 | + // Gitignored files in dark gray |
| 912 | + let padded = format!("{:<width$}", display_base, width = width); |
| 913 | + execute!( |
| 914 | + self.stdout, |
| 915 | + SetBackgroundColor(BG_COLOR), |
| 916 | + SetForegroundColor(Color::DarkGrey), |
| 917 | + Print(&padded), |
| 918 | + ResetColor |
| 919 | + )?; |
| 418 | 920 | } else { |
| 419 | | - // Files in default color |
| 921 | + // Files in default color with git status |
| 922 | + let padded_len = width.saturating_sub(indicator_display_len); |
| 923 | + let padded = format!("{:<width$}", display_base, width = padded_len); |
| 420 | 924 | execute!( |
| 421 | 925 | self.stdout, |
| 422 | 926 | SetBackgroundColor(BG_COLOR), |
| 423 | 927 | SetForegroundColor(Color::Reset), |
| 424 | 928 | Print(&padded), |
| 425 | | - ResetColor |
| 426 | 929 | )?; |
| 930 | + // Add git status indicator |
| 931 | + if item.git_status.staged { |
| 932 | + execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?; |
| 933 | + } else if item.git_status.unstaged { |
| 934 | + execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?; |
| 935 | + } else if item.git_status.untracked { |
| 936 | + execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?; |
| 937 | + } else if item.git_status.incoming { |
| 938 | + execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?; |
| 939 | + } |
| 940 | + execute!(self.stdout, ResetColor)?; |
| 427 | 941 | } |
| 428 | 942 | } else { |
| 429 | 943 | // Empty row |
@@ -437,14 +951,14 @@ impl Screen { |
| 437 | 951 | } |
| 438 | 952 | } |
| 439 | 953 | |
| 440 | | - // Draw hints at bottom |
| 441 | | - let hint_start = tree_rows; |
| 954 | + // Draw hints at bottom (after header + tree) |
| 955 | + let hint_start = header_rows + tree_rows; |
| 442 | 956 | if hints_expanded { |
| 443 | 957 | let hints = [ |
| 444 | | - "j/k:nav spc:toggle o:open .:hidden", |
| 445 | | - "ctrl-b:close ctrl-/:hints", |
| 446 | | - "", |
| 447 | | - "", |
| 958 | + "j/k:nav spc:toggle o:open .:hidden", |
| 959 | + "a:stage u:unstage d:diff m:commit", |
| 960 | + "p:push l:pull f:fetch t:tag", |
| 961 | + "ctrl-b:close ctrl-/:hints", |
| 448 | 962 | ]; |
| 449 | 963 | for (i, hint) in hints.iter().enumerate() { |
| 450 | 964 | if hint_start + i < text_rows { |
@@ -477,7 +991,7 @@ impl Screen { |
| 477 | 991 | Ok(()) |
| 478 | 992 | } |
| 479 | 993 | |
| 480 | | - /// Render the editor view with a horizontal offset (for fuss mode) |
| 994 | + /// Render the editor view with horizontal and vertical offsets (for fuss mode and tab bar) |
| 481 | 995 | pub fn render_with_offset( |
| 482 | 996 | &mut self, |
| 483 | 997 | buffer: &Buffer, |
@@ -486,12 +1000,14 @@ impl Screen { |
| 486 | 1000 | filename: Option<&str>, |
| 487 | 1001 | message: Option<&str>, |
| 488 | 1002 | bracket_match: Option<(usize, usize)>, |
| 489 | | - offset: u16, |
| 1003 | + left_offset: u16, |
| 1004 | + top_offset: u16, |
| 1005 | + is_modified: bool, |
| 490 | 1006 | ) -> Result<()> { |
| 491 | 1007 | // Hide cursor during render to prevent flicker |
| 492 | 1008 | execute!(self.stdout, Hide)?; |
| 493 | 1009 | |
| 494 | | - let available_cols = self.cols.saturating_sub(offset) as usize; |
| 1010 | + let available_cols = self.cols.saturating_sub(left_offset) as usize; |
| 495 | 1011 | let line_num_width = self.line_number_width(buffer.line_count()); |
| 496 | 1012 | let text_cols = available_cols.saturating_sub(line_num_width + 1); |
| 497 | 1013 | |
@@ -512,14 +1028,14 @@ impl Screen { |
| 512 | 1028 | .map(|(i, c)| (c.line, c.col, i == primary_idx)) |
| 513 | 1029 | .collect(); |
| 514 | 1030 | |
| 515 | | - // Reserve 1 row for status bar |
| 516 | | - let text_rows = self.rows.saturating_sub(1) as usize; |
| 1031 | + // Reserve 1 row for status bar, accounting for top offset |
| 1032 | + let text_rows = self.rows.saturating_sub(1 + top_offset) as usize; |
| 517 | 1033 | |
| 518 | 1034 | // Draw text area |
| 519 | 1035 | for row in 0..text_rows { |
| 520 | 1036 | let line_idx = viewport_line + row; |
| 521 | 1037 | let is_current_line = line_idx == primary.line; |
| 522 | | - execute!(self.stdout, MoveTo(offset, row as u16))?; |
| 1038 | + execute!(self.stdout, MoveTo(left_offset, (row as u16) + top_offset))?; |
| 523 | 1039 | |
| 524 | 1040 | if line_idx < buffer.line_count() { |
| 525 | 1041 | let line_num_fg = if is_current_line { |
@@ -576,14 +1092,14 @@ impl Screen { |
| 576 | 1092 | } |
| 577 | 1093 | |
| 578 | 1094 | // Status bar |
| 579 | | - self.render_status_bar_with_offset(buffer, cursors, filename, message, offset)?; |
| 1095 | + self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?; |
| 580 | 1096 | |
| 581 | 1097 | // Position hardware cursor at primary cursor |
| 582 | | - let cursor_row = primary.line.saturating_sub(viewport_line); |
| 583 | | - let cursor_col = offset as usize + line_num_width + 1 + primary.col; |
| 1098 | + let cursor_row = (primary.line.saturating_sub(viewport_line) as u16) + top_offset; |
| 1099 | + let cursor_col = left_offset as usize + line_num_width + 1 + primary.col; |
| 584 | 1100 | execute!( |
| 585 | 1101 | self.stdout, |
| 586 | | - MoveTo(cursor_col as u16, cursor_row as u16), |
| 1102 | + MoveTo(cursor_col as u16, cursor_row), |
| 587 | 1103 | Show |
| 588 | 1104 | )?; |
| 589 | 1105 | |
@@ -593,11 +1109,11 @@ impl Screen { |
| 593 | 1109 | |
| 594 | 1110 | fn render_status_bar_with_offset( |
| 595 | 1111 | &mut self, |
| 596 | | - buffer: &Buffer, |
| 597 | 1112 | cursors: &Cursors, |
| 598 | 1113 | filename: Option<&str>, |
| 599 | 1114 | message: Option<&str>, |
| 600 | 1115 | offset: u16, |
| 1116 | + is_modified: bool, |
| 601 | 1117 | ) -> Result<()> { |
| 602 | 1118 | let status_row = self.rows.saturating_sub(1); |
| 603 | 1119 | let available_cols = self.cols.saturating_sub(offset) as usize; |
@@ -610,7 +1126,7 @@ impl Screen { |
| 610 | 1126 | )?; |
| 611 | 1127 | |
| 612 | 1128 | let name = filename.unwrap_or("[No Name]"); |
| 613 | | - let modified = if buffer.modified { " [+]" } else { "" }; |
| 1129 | + let modified = if is_modified { " [+]" } else { "" }; |
| 614 | 1130 | let cursor_count = if cursors.len() > 1 { |
| 615 | 1131 | format!(" ({} cursors)", cursors.len()) |
| 616 | 1132 | } else { |
@@ -639,4 +1155,226 @@ impl Screen { |
| 639 | 1155 | |
| 640 | 1156 | Ok(()) |
| 641 | 1157 | } |
| 1158 | + |
| 1159 | + /// Render the welcome menu |
| 1160 | + pub fn render_welcome( |
| 1161 | + &mut self, |
| 1162 | + items: &[(String, String, bool, bool)], // (label, path, is_selected, is_current_dir) |
| 1163 | + scroll: usize, |
| 1164 | + ) -> Result<()> { |
| 1165 | + execute!(self.stdout, Hide)?; |
| 1166 | + |
| 1167 | + let cols = self.cols as usize; |
| 1168 | + let rows = self.rows as usize; |
| 1169 | + |
| 1170 | + // Fill background |
| 1171 | + for row in 0..rows { |
| 1172 | + execute!( |
| 1173 | + self.stdout, |
| 1174 | + MoveTo(0, row as u16), |
| 1175 | + SetBackgroundColor(BG_COLOR), |
| 1176 | + Clear(ClearType::UntilNewLine), |
| 1177 | + )?; |
| 1178 | + } |
| 1179 | + |
| 1180 | + // Calculate box dimensions |
| 1181 | + let box_width = cols.min(60).max(40); |
| 1182 | + let box_height = rows.saturating_sub(4).min(items.len() + 6).max(10); |
| 1183 | + let box_x = (cols.saturating_sub(box_width)) / 2; |
| 1184 | + let box_y = (rows.saturating_sub(box_height)) / 2; |
| 1185 | + |
| 1186 | + // Draw box border |
| 1187 | + let top_border = format!("╭{}╮", "─".repeat(box_width.saturating_sub(2))); |
| 1188 | + let bottom_border = format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))); |
| 1189 | + |
| 1190 | + execute!( |
| 1191 | + self.stdout, |
| 1192 | + MoveTo(box_x as u16, box_y as u16), |
| 1193 | + SetBackgroundColor(BG_COLOR), |
| 1194 | + SetForegroundColor(Color::DarkGrey), |
| 1195 | + Print(&top_border), |
| 1196 | + )?; |
| 1197 | + |
| 1198 | + // Title |
| 1199 | + let title = "Welcome to fackr"; |
| 1200 | + let title_row = box_y + 1; |
| 1201 | + let title_x = box_x + (box_width.saturating_sub(title.len())) / 2; |
| 1202 | + execute!( |
| 1203 | + self.stdout, |
| 1204 | + MoveTo(box_x as u16, title_row as u16), |
| 1205 | + SetForegroundColor(Color::DarkGrey), |
| 1206 | + Print("│"), |
| 1207 | + SetForegroundColor(Color::White), |
| 1208 | + )?; |
| 1209 | + let padding_left = title_x.saturating_sub(box_x + 1); |
| 1210 | + let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + title.len()); |
| 1211 | + execute!( |
| 1212 | + self.stdout, |
| 1213 | + Print(&" ".repeat(padding_left)), |
| 1214 | + Print(title), |
| 1215 | + Print(&" ".repeat(padding_right)), |
| 1216 | + SetForegroundColor(Color::DarkGrey), |
| 1217 | + Print("│"), |
| 1218 | + )?; |
| 1219 | + |
| 1220 | + // Subtitle |
| 1221 | + let subtitle = "Select a workspace:"; |
| 1222 | + let subtitle_row = box_y + 2; |
| 1223 | + execute!( |
| 1224 | + self.stdout, |
| 1225 | + MoveTo(box_x as u16, subtitle_row as u16), |
| 1226 | + SetForegroundColor(Color::DarkGrey), |
| 1227 | + Print("│"), |
| 1228 | + SetForegroundColor(Color::AnsiValue(245)), |
| 1229 | + )?; |
| 1230 | + let padding_left = (box_width.saturating_sub(2).saturating_sub(subtitle.len())) / 2; |
| 1231 | + let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + subtitle.len()); |
| 1232 | + execute!( |
| 1233 | + self.stdout, |
| 1234 | + Print(&" ".repeat(padding_left)), |
| 1235 | + Print(subtitle), |
| 1236 | + Print(&" ".repeat(padding_right)), |
| 1237 | + SetForegroundColor(Color::DarkGrey), |
| 1238 | + Print("│"), |
| 1239 | + )?; |
| 1240 | + |
| 1241 | + // Separator |
| 1242 | + let separator_row = box_y + 3; |
| 1243 | + execute!( |
| 1244 | + self.stdout, |
| 1245 | + MoveTo(box_x as u16, separator_row as u16), |
| 1246 | + SetForegroundColor(Color::DarkGrey), |
| 1247 | + Print("├"), |
| 1248 | + Print(&"─".repeat(box_width.saturating_sub(2))), |
| 1249 | + Print("┤"), |
| 1250 | + )?; |
| 1251 | + |
| 1252 | + // Item list area |
| 1253 | + let list_start_row = box_y + 4; |
| 1254 | + let list_height = box_height.saturating_sub(6); |
| 1255 | + let inner_width = box_width.saturating_sub(4); |
| 1256 | + |
| 1257 | + for i in 0..list_height { |
| 1258 | + let row = list_start_row + i; |
| 1259 | + let item_idx = scroll + i; |
| 1260 | + |
| 1261 | + execute!( |
| 1262 | + self.stdout, |
| 1263 | + MoveTo(box_x as u16, row as u16), |
| 1264 | + SetForegroundColor(Color::DarkGrey), |
| 1265 | + Print("│ "), |
| 1266 | + )?; |
| 1267 | + |
| 1268 | + if item_idx < items.len() { |
| 1269 | + let (label, _path, is_selected, is_current_dir) = &items[item_idx]; |
| 1270 | + |
| 1271 | + // Truncate label to fit |
| 1272 | + let display_label: String = label.chars().take(inner_width).collect(); |
| 1273 | + let padded = format!("{:<width$}", display_label, width = inner_width); |
| 1274 | + |
| 1275 | + if *is_selected { |
| 1276 | + execute!( |
| 1277 | + self.stdout, |
| 1278 | + SetBackgroundColor(Color::DarkGrey), |
| 1279 | + SetForegroundColor(Color::White), |
| 1280 | + Print(&padded), |
| 1281 | + SetBackgroundColor(BG_COLOR), |
| 1282 | + )?; |
| 1283 | + } else if *is_current_dir { |
| 1284 | + execute!( |
| 1285 | + self.stdout, |
| 1286 | + SetForegroundColor(Color::Cyan), |
| 1287 | + Print(&padded), |
| 1288 | + )?; |
| 1289 | + } else { |
| 1290 | + execute!( |
| 1291 | + self.stdout, |
| 1292 | + SetForegroundColor(Color::Reset), |
| 1293 | + Print(&padded), |
| 1294 | + )?; |
| 1295 | + } |
| 1296 | + |
| 1297 | + // Show path hint for selected item |
| 1298 | + if *is_selected && inner_width > 30 { |
| 1299 | + // Clear and show path below |
| 1300 | + } |
| 1301 | + } else { |
| 1302 | + execute!( |
| 1303 | + self.stdout, |
| 1304 | + SetForegroundColor(Color::Reset), |
| 1305 | + Print(&" ".repeat(inner_width)), |
| 1306 | + )?; |
| 1307 | + } |
| 1308 | + |
| 1309 | + execute!( |
| 1310 | + self.stdout, |
| 1311 | + SetForegroundColor(Color::DarkGrey), |
| 1312 | + Print(" │"), |
| 1313 | + )?; |
| 1314 | + } |
| 1315 | + |
| 1316 | + // Path display row (show selected path) |
| 1317 | + let path_row = list_start_row + list_height; |
| 1318 | + execute!( |
| 1319 | + self.stdout, |
| 1320 | + MoveTo(box_x as u16, path_row as u16), |
| 1321 | + SetForegroundColor(Color::DarkGrey), |
| 1322 | + Print("├"), |
| 1323 | + Print(&"─".repeat(box_width.saturating_sub(2))), |
| 1324 | + Print("┤"), |
| 1325 | + )?; |
| 1326 | + |
| 1327 | + // Show selected path |
| 1328 | + let selected_item = items.iter().find(|(_, _, sel, _)| *sel); |
| 1329 | + let path_display_row = path_row + 1; |
| 1330 | + execute!( |
| 1331 | + self.stdout, |
| 1332 | + MoveTo(box_x as u16, path_display_row as u16), |
| 1333 | + SetForegroundColor(Color::DarkGrey), |
| 1334 | + Print("│ "), |
| 1335 | + )?; |
| 1336 | + if let Some((_, path, _, _)) = selected_item { |
| 1337 | + let truncated_path: String = path.chars().take(inner_width).collect(); |
| 1338 | + let padded_path = format!("{:<width$}", truncated_path, width = inner_width); |
| 1339 | + execute!( |
| 1340 | + self.stdout, |
| 1341 | + SetForegroundColor(Color::AnsiValue(240)), |
| 1342 | + Print(&padded_path), |
| 1343 | + )?; |
| 1344 | + } else { |
| 1345 | + execute!( |
| 1346 | + self.stdout, |
| 1347 | + Print(&" ".repeat(inner_width)), |
| 1348 | + )?; |
| 1349 | + } |
| 1350 | + execute!( |
| 1351 | + self.stdout, |
| 1352 | + SetForegroundColor(Color::DarkGrey), |
| 1353 | + Print(" │"), |
| 1354 | + )?; |
| 1355 | + |
| 1356 | + // Bottom border |
| 1357 | + let bottom_row = path_display_row + 1; |
| 1358 | + execute!( |
| 1359 | + self.stdout, |
| 1360 | + MoveTo(box_x as u16, bottom_row as u16), |
| 1361 | + SetForegroundColor(Color::DarkGrey), |
| 1362 | + Print(&bottom_border), |
| 1363 | + )?; |
| 1364 | + |
| 1365 | + // Hints at bottom |
| 1366 | + let hint_row = bottom_row + 1; |
| 1367 | + let hints = "↑/↓: navigate Enter: select ESC: quit"; |
| 1368 | + let hints_x = (cols.saturating_sub(hints.len())) / 2; |
| 1369 | + execute!( |
| 1370 | + self.stdout, |
| 1371 | + MoveTo(hints_x as u16, hint_row as u16), |
| 1372 | + SetForegroundColor(Color::AnsiValue(240)), |
| 1373 | + Print(hints), |
| 1374 | + ResetColor, |
| 1375 | + )?; |
| 1376 | + |
| 1377 | + self.stdout.flush()?; |
| 1378 | + Ok(()) |
| 1379 | + } |
| 642 | 1380 | } |