@@ -78,6 +78,24 @@ pub struct TerminalScreen { |
| 78 | 78 | max_scrollback: usize, |
| 79 | 79 | /// Scroll offset (0 = at bottom) |
| 80 | 80 | pub scroll_offset: usize, |
| 81 | + /// DEC private modes |
| 82 | + pub cursor_visible: bool, |
| 83 | + autowrap: bool, |
| 84 | + application_cursor_keys: bool, |
| 85 | + bracketed_paste: bool, |
| 86 | + /// Alternate screen buffer |
| 87 | + alt_cells: Option<Vec<Vec<Cell>>>, |
| 88 | + alt_cursor_row: u16, |
| 89 | + alt_cursor_col: u16, |
| 90 | + using_alt_screen: bool, |
| 91 | + /// Saved cursor position (for ESC 7/8 and CSI s/u) |
| 92 | + saved_cursor_row: u16, |
| 93 | + saved_cursor_col: u16, |
| 94 | + /// Scroll region (top, bottom) - 0-indexed, inclusive |
| 95 | + scroll_top: u16, |
| 96 | + scroll_bottom: u16, |
| 97 | + /// Response queue for device status reports |
| 98 | + response_queue: Vec<Vec<u8>>, |
| 81 | 99 | } |
| 82 | 100 | |
| 83 | 101 | impl TerminalScreen { |
@@ -98,6 +116,24 @@ impl TerminalScreen { |
| 98 | 116 | scrollback: Vec::new(), |
| 99 | 117 | max_scrollback: 10000, |
| 100 | 118 | scroll_offset: 0, |
| 119 | + // DEC private modes |
| 120 | + cursor_visible: true, |
| 121 | + autowrap: true, |
| 122 | + application_cursor_keys: false, |
| 123 | + bracketed_paste: false, |
| 124 | + // Alternate screen buffer |
| 125 | + alt_cells: None, |
| 126 | + alt_cursor_row: 0, |
| 127 | + alt_cursor_col: 0, |
| 128 | + using_alt_screen: false, |
| 129 | + // Saved cursor |
| 130 | + saved_cursor_row: 0, |
| 131 | + saved_cursor_col: 0, |
| 132 | + // Scroll region (full screen by default) |
| 133 | + scroll_top: 0, |
| 134 | + scroll_bottom: rows.saturating_sub(1), |
| 135 | + // Response queue |
| 136 | + response_queue: Vec::new(), |
| 101 | 137 | } |
| 102 | 138 | } |
| 103 | 139 | |
@@ -156,6 +192,216 @@ impl TerminalScreen { |
| 156 | 192 | // Ensure cursor is within bounds |
| 157 | 193 | self.cursor_row = self.cursor_row.min(rows.saturating_sub(1)); |
| 158 | 194 | self.cursor_col = self.cursor_col.min(cols.saturating_sub(1)); |
| 195 | + |
| 196 | + // Update scroll region to new size |
| 197 | + self.scroll_bottom = rows.saturating_sub(1); |
| 198 | + if self.scroll_top > self.scroll_bottom { |
| 199 | + self.scroll_top = 0; |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + /// Drain response queue (for device status reports) |
| 204 | + pub fn drain_responses(&mut self) -> Vec<Vec<u8>> { |
| 205 | + std::mem::take(&mut self.response_queue) |
| 206 | + } |
| 207 | + |
| 208 | + /// Enter alternate screen buffer |
| 209 | + fn enter_alt_screen(&mut self) { |
| 210 | + if !self.using_alt_screen { |
| 211 | + // Save primary screen and cursor |
| 212 | + self.alt_cells = Some(std::mem::take(&mut self.cells)); |
| 213 | + self.alt_cursor_row = self.cursor_row; |
| 214 | + self.alt_cursor_col = self.cursor_col; |
| 215 | + // Create fresh alt screen |
| 216 | + self.cells = vec![vec![Cell::default(); self.cols as usize]; self.rows as usize]; |
| 217 | + self.cursor_row = 0; |
| 218 | + self.cursor_col = 0; |
| 219 | + self.using_alt_screen = true; |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + /// Leave alternate screen buffer |
| 224 | + fn leave_alt_screen(&mut self) { |
| 225 | + if self.using_alt_screen { |
| 226 | + if let Some(primary) = self.alt_cells.take() { |
| 227 | + self.cells = primary; |
| 228 | + self.cursor_row = self.alt_cursor_row; |
| 229 | + self.cursor_col = self.alt_cursor_col; |
| 230 | + } |
| 231 | + self.using_alt_screen = false; |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + /// Save cursor position |
| 236 | + fn save_cursor(&mut self) { |
| 237 | + self.saved_cursor_row = self.cursor_row; |
| 238 | + self.saved_cursor_col = self.cursor_col; |
| 239 | + } |
| 240 | + |
| 241 | + /// Restore cursor position |
| 242 | + fn restore_cursor(&mut self) { |
| 243 | + self.cursor_row = self.saved_cursor_row.min(self.rows.saturating_sub(1)); |
| 244 | + self.cursor_col = self.saved_cursor_col.min(self.cols.saturating_sub(1)); |
| 245 | + } |
| 246 | + |
| 247 | + /// Handle DEC private mode set/reset |
| 248 | + fn handle_dec_private_mode(&mut self, params: &[u16], set: bool) { |
| 249 | + for ¶m in params { |
| 250 | + match param { |
| 251 | + 1 => self.application_cursor_keys = set, // DECCKM |
| 252 | + 7 => self.autowrap = set, // DECAWM |
| 253 | + 25 => self.cursor_visible = set, // DECTCEM |
| 254 | + 1049 => { |
| 255 | + // Alternate screen buffer |
| 256 | + if set { |
| 257 | + self.enter_alt_screen(); |
| 258 | + } else { |
| 259 | + self.leave_alt_screen(); |
| 260 | + } |
| 261 | + } |
| 262 | + 2004 => self.bracketed_paste = set, // Bracketed paste |
| 263 | + _ => {} // Ignore unknown modes |
| 264 | + } |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + /// Reverse index - move cursor up, scroll down if at top |
| 269 | + fn reverse_index(&mut self) { |
| 270 | + if self.cursor_row == self.scroll_top { |
| 271 | + self.scroll_down_region(1); |
| 272 | + } else { |
| 273 | + self.cursor_row = self.cursor_row.saturating_sub(1); |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + /// Index - move cursor down, scroll up if at bottom |
| 278 | + fn index(&mut self) { |
| 279 | + if self.cursor_row == self.scroll_bottom { |
| 280 | + self.scroll_up_region(1); |
| 281 | + } else if self.cursor_row < self.rows - 1 { |
| 282 | + self.cursor_row += 1; |
| 283 | + } |
| 284 | + } |
| 285 | + |
| 286 | + /// Next line - move to start of next line, scroll if needed |
| 287 | + fn next_line(&mut self) { |
| 288 | + self.cursor_col = 0; |
| 289 | + self.index(); |
| 290 | + } |
| 291 | + |
| 292 | + /// Scroll up within scroll region |
| 293 | + fn scroll_up_region(&mut self, n: u16) { |
| 294 | + let top = self.scroll_top as usize; |
| 295 | + let bottom = self.scroll_bottom as usize; |
| 296 | + |
| 297 | + for _ in 0..n { |
| 298 | + if top < self.cells.len() && bottom < self.cells.len() && top <= bottom { |
| 299 | + // Move top row to scrollback (only if scroll region is full screen) |
| 300 | + if self.scroll_top == 0 && self.scroll_bottom == self.rows - 1 { |
| 301 | + let top_row = self.cells.remove(top); |
| 302 | + self.scrollback.push(top_row); |
| 303 | + if self.scrollback.len() > self.max_scrollback { |
| 304 | + self.scrollback.remove(0); |
| 305 | + } |
| 306 | + } else { |
| 307 | + self.cells.remove(top); |
| 308 | + } |
| 309 | + // Insert new row at bottom of scroll region |
| 310 | + self.cells.insert(bottom, vec![Cell::default(); self.cols as usize]); |
| 311 | + } |
| 312 | + } |
| 313 | + } |
| 314 | + |
| 315 | + /// Scroll down within scroll region |
| 316 | + fn scroll_down_region(&mut self, n: u16) { |
| 317 | + let top = self.scroll_top as usize; |
| 318 | + let bottom = self.scroll_bottom as usize; |
| 319 | + |
| 320 | + for _ in 0..n { |
| 321 | + if top < self.cells.len() && bottom < self.cells.len() && top <= bottom { |
| 322 | + // Remove bottom row |
| 323 | + self.cells.remove(bottom); |
| 324 | + // Insert new row at top of scroll region |
| 325 | + self.cells.insert(top, vec![Cell::default(); self.cols as usize]); |
| 326 | + } |
| 327 | + } |
| 328 | + } |
| 329 | + |
| 330 | + /// Insert n lines at cursor position |
| 331 | + fn insert_lines(&mut self, n: u16) { |
| 332 | + let row = self.cursor_row as usize; |
| 333 | + let bottom = self.scroll_bottom as usize; |
| 334 | + |
| 335 | + for _ in 0..n { |
| 336 | + if row <= bottom && bottom < self.cells.len() { |
| 337 | + self.cells.remove(bottom); |
| 338 | + self.cells.insert(row, vec![Cell::default(); self.cols as usize]); |
| 339 | + } |
| 340 | + } |
| 341 | + } |
| 342 | + |
| 343 | + /// Delete n lines at cursor position |
| 344 | + fn delete_lines(&mut self, n: u16) { |
| 345 | + let row = self.cursor_row as usize; |
| 346 | + let bottom = self.scroll_bottom as usize; |
| 347 | + |
| 348 | + for _ in 0..n { |
| 349 | + if row <= bottom && row < self.cells.len() { |
| 350 | + self.cells.remove(row); |
| 351 | + self.cells.insert(bottom, vec![Cell::default(); self.cols as usize]); |
| 352 | + } |
| 353 | + } |
| 354 | + } |
| 355 | + |
| 356 | + /// Insert n blank characters at cursor position |
| 357 | + fn insert_chars(&mut self, n: u16) { |
| 358 | + if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
| 359 | + let col = self.cursor_col as usize; |
| 360 | + for _ in 0..n { |
| 361 | + if col < row.len() { |
| 362 | + row.pop(); // Remove from end |
| 363 | + row.insert(col, Cell::default()); // Insert at cursor |
| 364 | + } |
| 365 | + } |
| 366 | + } |
| 367 | + } |
| 368 | + |
| 369 | + /// Delete n characters at cursor position |
| 370 | + fn delete_chars(&mut self, n: u16) { |
| 371 | + if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
| 372 | + let col = self.cursor_col as usize; |
| 373 | + for _ in 0..n { |
| 374 | + if col < row.len() { |
| 375 | + row.remove(col); |
| 376 | + row.push(Cell::default()); // Add blank at end |
| 377 | + } |
| 378 | + } |
| 379 | + } |
| 380 | + } |
| 381 | + |
| 382 | + /// Clear from start of screen to cursor |
| 383 | + fn clear_from_start(&mut self) { |
| 384 | + // Clear all rows before cursor row |
| 385 | + for row in self.cells.iter_mut().take(self.cursor_row as usize) { |
| 386 | + for cell in row.iter_mut() { |
| 387 | + *cell = Cell::default(); |
| 388 | + } |
| 389 | + } |
| 390 | + // Clear current row from start to cursor |
| 391 | + if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
| 392 | + for cell in row.iter_mut().take(self.cursor_col as usize + 1) { |
| 393 | + *cell = Cell::default(); |
| 394 | + } |
| 395 | + } |
| 396 | + } |
| 397 | + |
| 398 | + /// Clear from start of line to cursor |
| 399 | + fn clear_line_from_start(&mut self) { |
| 400 | + if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
| 401 | + for cell in row.iter_mut().take(self.cursor_col as usize + 1) { |
| 402 | + *cell = Cell::default(); |
| 403 | + } |
| 404 | + } |
| 159 | 405 | } |
| 160 | 406 | |
| 161 | 407 | /// Scroll the screen up by one line |
@@ -274,9 +520,21 @@ impl Perform for TerminalScreen { |
| 274 | 520 | |
| 275 | 521 | fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} |
| 276 | 522 | |
| 277 | | - fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, action: char) { |
| 523 | + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { |
| 278 | 524 | let params: Vec<u16> = params.iter().map(|p| p.first().copied().unwrap_or(0) as u16).collect(); |
| 279 | 525 | |
| 526 | + // Check for DEC private mode sequences (CSI ? ...) |
| 527 | + let is_private = intermediates.contains(&b'?'); |
| 528 | + |
| 529 | + if is_private { |
| 530 | + match action { |
| 531 | + 'h' => self.handle_dec_private_mode(¶ms, true), // Set mode |
| 532 | + 'l' => self.handle_dec_private_mode(¶ms, false), // Reset mode |
| 533 | + _ => {} |
| 534 | + } |
| 535 | + return; |
| 536 | + } |
| 537 | + |
| 280 | 538 | match action { |
| 281 | 539 | // Cursor Up |
| 282 | 540 | 'A' => { |
@@ -298,6 +556,23 @@ impl Perform for TerminalScreen { |
| 298 | 556 | let n = params.first().copied().unwrap_or(1).max(1); |
| 299 | 557 | self.cursor_col = self.cursor_col.saturating_sub(n); |
| 300 | 558 | } |
| 559 | + // Cursor Next Line |
| 560 | + 'E' => { |
| 561 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 562 | + self.cursor_col = 0; |
| 563 | + self.cursor_row = (self.cursor_row + n).min(self.rows - 1); |
| 564 | + } |
| 565 | + // Cursor Previous Line |
| 566 | + 'F' => { |
| 567 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 568 | + self.cursor_col = 0; |
| 569 | + self.cursor_row = self.cursor_row.saturating_sub(n); |
| 570 | + } |
| 571 | + // Cursor Horizontal Absolute |
| 572 | + 'G' => { |
| 573 | + let col = params.first().copied().unwrap_or(1).max(1) - 1; |
| 574 | + self.cursor_col = col.min(self.cols - 1); |
| 575 | + } |
| 301 | 576 | // Cursor Position (CUP) |
| 302 | 577 | 'H' | 'f' => { |
| 303 | 578 | let row = params.first().copied().unwrap_or(1).max(1) - 1; |
@@ -310,7 +585,7 @@ impl Perform for TerminalScreen { |
| 310 | 585 | let mode = params.first().copied().unwrap_or(0); |
| 311 | 586 | match mode { |
| 312 | 587 | 0 => self.clear_to_eos(), |
| 313 | | - 1 => {} // Clear from start to cursor (TODO) |
| 588 | + 1 => self.clear_from_start(), |
| 314 | 589 | 2 | 3 => self.clear_screen(), |
| 315 | 590 | _ => {} |
| 316 | 591 | } |
@@ -320,7 +595,7 @@ impl Perform for TerminalScreen { |
| 320 | 595 | let mode = params.first().copied().unwrap_or(0); |
| 321 | 596 | match mode { |
| 322 | 597 | 0 => self.clear_to_eol(), |
| 323 | | - 1 => {} // Clear from start of line to cursor (TODO) |
| 598 | + 1 => self.clear_line_from_start(), |
| 324 | 599 | 2 => { |
| 325 | 600 | // Clear entire line |
| 326 | 601 | if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
@@ -332,6 +607,89 @@ impl Perform for TerminalScreen { |
| 332 | 607 | _ => {} |
| 333 | 608 | } |
| 334 | 609 | } |
| 610 | + // Insert Lines |
| 611 | + 'L' => { |
| 612 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 613 | + self.insert_lines(n); |
| 614 | + } |
| 615 | + // Delete Lines |
| 616 | + 'M' => { |
| 617 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 618 | + self.delete_lines(n); |
| 619 | + } |
| 620 | + // Delete Characters |
| 621 | + 'P' => { |
| 622 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 623 | + self.delete_chars(n); |
| 624 | + } |
| 625 | + // Scroll Up |
| 626 | + 'S' => { |
| 627 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 628 | + self.scroll_up_region(n); |
| 629 | + } |
| 630 | + // Scroll Down |
| 631 | + 'T' => { |
| 632 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 633 | + self.scroll_down_region(n); |
| 634 | + } |
| 635 | + // Erase Characters |
| 636 | + 'X' => { |
| 637 | + let n = params.first().copied().unwrap_or(1).max(1) as usize; |
| 638 | + if let Some(row) = self.cells.get_mut(self.cursor_row as usize) { |
| 639 | + for i in 0..n { |
| 640 | + let col = self.cursor_col as usize + i; |
| 641 | + if col < row.len() { |
| 642 | + row[col] = Cell::default(); |
| 643 | + } |
| 644 | + } |
| 645 | + } |
| 646 | + } |
| 647 | + // Insert Characters |
| 648 | + '@' => { |
| 649 | + let n = params.first().copied().unwrap_or(1).max(1); |
| 650 | + self.insert_chars(n); |
| 651 | + } |
| 652 | + // Cursor Vertical Absolute |
| 653 | + 'd' => { |
| 654 | + let row = params.first().copied().unwrap_or(1).max(1) - 1; |
| 655 | + self.cursor_row = row.min(self.rows - 1); |
| 656 | + } |
| 657 | + // Device Status Report |
| 658 | + 'n' => { |
| 659 | + let mode = params.first().copied().unwrap_or(0); |
| 660 | + match mode { |
| 661 | + 5 => { |
| 662 | + // Status report - respond "OK" |
| 663 | + self.response_queue.push(b"\x1b[0n".to_vec()); |
| 664 | + } |
| 665 | + 6 => { |
| 666 | + // Cursor position report |
| 667 | + let response = format!("\x1b[{};{}R", self.cursor_row + 1, self.cursor_col + 1); |
| 668 | + self.response_queue.push(response.into_bytes()); |
| 669 | + } |
| 670 | + _ => {} |
| 671 | + } |
| 672 | + } |
| 673 | + // Set Scroll Region (DECSTBM) |
| 674 | + 'r' => { |
| 675 | + let top = params.first().copied().unwrap_or(1).max(1) - 1; |
| 676 | + let bottom = params.get(1).copied().unwrap_or(self.rows).max(1) - 1; |
| 677 | + if top < bottom && bottom < self.rows { |
| 678 | + self.scroll_top = top; |
| 679 | + self.scroll_bottom = bottom; |
| 680 | + // Move cursor to home position |
| 681 | + self.cursor_row = 0; |
| 682 | + self.cursor_col = 0; |
| 683 | + } |
| 684 | + } |
| 685 | + // Save Cursor Position |
| 686 | + 's' => { |
| 687 | + self.save_cursor(); |
| 688 | + } |
| 689 | + // Restore Cursor Position |
| 690 | + 'u' => { |
| 691 | + self.restore_cursor(); |
| 692 | + } |
| 335 | 693 | // Select Graphic Rendition (SGR) - colors and attributes |
| 336 | 694 | 'm' => { |
| 337 | 695 | if params.is_empty() { |
@@ -446,5 +804,33 @@ impl Perform for TerminalScreen { |
| 446 | 804 | } |
| 447 | 805 | } |
| 448 | 806 | |
| 449 | | - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} |
| 807 | + fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) { |
| 808 | + match (intermediates, byte) { |
| 809 | + // Save cursor position (DECSC) |
| 810 | + ([], b'7') => self.save_cursor(), |
| 811 | + // Restore cursor position (DECRC) |
| 812 | + ([], b'8') => self.restore_cursor(), |
| 813 | + // Reverse Index - move cursor up, scroll down if at top |
| 814 | + ([], b'M') => self.reverse_index(), |
| 815 | + // Index - move cursor down, scroll up if at bottom |
| 816 | + ([], b'D') => self.index(), |
| 817 | + // Next Line - move to start of next line |
| 818 | + ([], b'E') => self.next_line(), |
| 819 | + // Reset to Initial State (RIS) |
| 820 | + ([], b'c') => { |
| 821 | + // Full reset |
| 822 | + self.clear_screen(); |
| 823 | + self.cursor_row = 0; |
| 824 | + self.cursor_col = 0; |
| 825 | + self.current_fg = Color::Default; |
| 826 | + self.current_bg = Color::Default; |
| 827 | + self.current_bold = false; |
| 828 | + self.current_underline = false; |
| 829 | + self.current_inverse = false; |
| 830 | + self.scroll_top = 0; |
| 831 | + self.scroll_bottom = self.rows.saturating_sub(1); |
| 832 | + } |
| 833 | + _ => {} |
| 834 | + } |
| 835 | + } |
| 450 | 836 | } |