1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
// Copyright (c) 2024-2025 R3BL LLC. Licensed under Apache License, Version 2.0.
use super::core::LineState;
use crate::{CsiSequence, GCStringOwned, LINE_FEED_BYTE, LineStateLiveness, ReadlineError,
TermCol, TermColDelta, TermRowDelta, early_return_if_paused, ok, width};
use std::io::Write;
impl LineState {
/// Prints raw byte data to the terminal and re-renders the prompt.
///
/// This method handles the complex task of printing output from concurrent tasks
/// (via [`SharedWriter`]) while maintaining the readline prompt display. It:
///
/// 1. Clears the current line
/// 2. Restores cursor position if previous output didn't end with newline
/// 3. Writes the data with proper newline handling
/// 4. Re-renders the prompt and input line
///
/// # Errors
///
/// Returns an error if writing to the terminal fails.
///
/// [`SharedWriter`]: crate::SharedWriter
pub fn print_data_and_flush(
&mut self,
data: &[u8],
term: &mut dyn Write,
) -> Result<(), ReadlineError> {
self.clear(term)?;
// If last written data was not newline, restore the cursor.
if !self.last_line_completed {
// Move up 1 row, to column 0, then right to the last position.
term.write_all(
CsiSequence::CursorUp(TermRowDelta::ONE)
.to_string()
.as_bytes(),
)?;
term.write_all(
CsiSequence::CursorHorizontalAbsolute(TermCol::ONE)
.to_string()
.as_bytes(),
)?;
// Only emit CursorForward if the delta is non-zero (illegal states unrepresentable).
if let Some(cols_right) = TermColDelta::new(self.last_line_length.as_u16()) {
term.write_all(CsiSequence::CursorForward(cols_right).to_string().as_bytes())?;
}
}
// Write data in a way that newlines also act as carriage returns.
// In raw mode, LF doesn't auto-CR, so we explicitly move to column 1 after
// every segment. This ensures multi-line output displays correctly.
//
// For the last segment only: if it ends with newline, we skip the CHA(1)
// here since the subsequent CHA(1) before render_and_flush handles it.
// This avoids a redundant [LF][CHA(1)][CHA(1)] pattern that can cause
// visual artifacts (extra blank line) on some terminal emulators.
let col_0 = CsiSequence::CursorHorizontalAbsolute(TermCol::ONE).to_string();
let segments: Vec<_> = data.split_inclusive(|b| *b == LINE_FEED_BYTE).collect();
let last_idx = segments.len().saturating_sub(1);
for (idx, line) in segments.into_iter().enumerate() {
term.write_all(line)?;
// Emit CHA(1) after every segment EXCEPT the last one if it ends with
// newline. The final CHA(1) before render_and_flush handles that case.
let is_last = idx == last_idx;
let ends_with_newline = line.ends_with(&[LINE_FEED_BYTE]);
if !(is_last && ends_with_newline) {
term.write_all(col_0.as_bytes())?;
}
}
self.last_line_completed = data.ends_with(&[LINE_FEED_BYTE]); // Set whether data ends with newline
// If data does not end with newline, save the cursor and write newline for
// prompt. Usually data does end in newline due to the buffering of
// SharedWriter, but sometimes it may not (i.e. if .flush() is called).
if self.last_line_completed {
self.last_line_length = width(0);
} else {
// Add data length to last_line_length.
let new_len = self.last_line_length.as_usize() + data.len();
let term_width = self.term_size.col_width.as_usize();
// Make sure that last_line_length wraps around when doing multiple writes.
if new_len >= term_width {
self.last_line_length = width(new_len % term_width);
writeln!(term)?;
} else {
self.last_line_length = width(new_len);
}
writeln!(term)?; // Move to beginning of line and make new line
}
term.write_all(
CsiSequence::CursorHorizontalAbsolute(TermCol::ONE)
.to_string()
.as_bytes(),
)?;
self.render_and_flush(term)?;
ok!()
}
/// Prints a string to the terminal and re-renders the prompt.
///
/// This is a convenience wrapper around
/// [`print_data_and_flush`](Self::print_data_and_flush) that accepts a string
/// slice. Respects pause state - does nothing if paused.
///
/// # Errors
///
/// Returns an error if writing to the terminal fails.
pub fn print_and_flush(
&mut self,
string: &str,
term: &mut dyn Write,
) -> Result<(), ReadlineError> {
early_return_if_paused!(self @Unit);
self.print_data_and_flush(string.as_bytes(), term)?;
ok!()
}
/// Updates the prompt string and re-renders the line.
///
/// Use this to dynamically change the prompt (e.g., to show current directory
/// or command status).
///
/// # Errors
///
/// Returns an error if writing to the terminal fails.
pub fn update_prompt(
&mut self,
prompt: &str,
term: &mut dyn Write,
) -> Result<(), ReadlineError> {
self.clear(term)?;
self.prompt.clear();
self.prompt.push_str(prompt);
// Recalculates column.
self.move_cursor(0)?;
self.render_and_flush(term)?;
ok!()
}
/// Clears the line state and prepares the terminal for exit.
///
/// Called when the user presses Ctrl+C or Ctrl+D. Clears the current line
/// content and moves the cursor to column 0.
///
/// # Errors
///
/// Returns an error if writing to the terminal fails.
pub fn exit(&mut self, term: &mut dyn Write) -> Result<(), ReadlineError> {
self.line = GCStringOwned::new("");
self.clear(term)?;
term.write_all(
CsiSequence::CursorHorizontalAbsolute(TermCol::ONE)
.to_string()
.as_bytes(),
)?;
term.flush()?;
ok!()
}
/// Moves cursor to the beginning and re-renders the line.
///
/// Used after submitting a line (pressing Enter) to start fresh on a new line.
/// Respects pause state - does nothing if paused.
///
/// # Errors
///
/// Returns an error if writing to the terminal fails.
pub fn render_new_line_from_beginning_and_flush(
&mut self,
term: &mut dyn Write,
) -> Result<(), ReadlineError> {
early_return_if_paused!(self @Unit);
self.move_cursor(-100_000)?;
self.clear_and_render_and_flush(term)?;
ok!()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::test_fixtures::StdoutMock;
/// Helper to decode ANSI escape sequences in output for debugging.
fn describe_ansi_output(output: &[u8]) -> String {
use std::fmt::Write;
let mut result = String::new();
let mut i = 0;
while i < output.len() {
if output[i] == 0x1b && i + 1 < output.len() && output[i + 1] == b'[' {
// Parse CSI sequence.
let start = i;
i += 2;
let mut params = String::new();
while i < output.len() && (output[i].is_ascii_digit() || output[i] == b';') {
params.push(output[i] as char);
i += 1;
}
if i < output.len() {
let cmd = output[i] as char;
let desc = match cmd {
'A' => format!("CursorUp({params})"),
'B' => format!("CursorDown({params})"),
'C' => format!("CursorForward({params})"),
'D' => format!("CursorBackward({params})"),
'G' => format!("CHA({params})"),
'H' => format!("CUP({params})"),
'J' => format!("EraseDisplay({params})"),
'K' => format!("EraseLine({params})"),
_ => format!("CSI[{params}{cmd}]"),
};
write!(result, "[{desc}]").unwrap();
i += 1;
} else {
write!(result, "[CSI:incomplete@{start}]").unwrap();
}
} else if output[i] == b'\n' {
result.push_str("[LF]");
i += 1;
} else if output[i] == b'\r' {
result.push_str("[CR]");
i += 1;
} else if output[i].is_ascii_graphic() || output[i] == b' ' {
result.push(output[i] as char);
i += 1;
} else {
write!(result, "[0x{:02x}]", output[i]).unwrap();
i += 1;
}
}
result
}
/// Regression test for issue #442: extra blank line before prompt.
///
/// Verifies that `print_data_and_flush` with newline-terminated data produces
/// exactly one LF in the output, preventing extra blank lines before the prompt.
#[test]
fn test_print_data_no_extra_newlines_issue_442() {
let mut line_state = LineState::new("> ".into(), (80, 24));
let mut stdout_mock = StdoutMock::default();
// Simulate initial state: prompt has been rendered.
line_state.render_and_flush(&mut stdout_mock).unwrap();
stdout_mock.buffer.lock().unwrap().clear();
// First log line (ends with newline).
line_state
.print_data_and_flush(b"line 1\n", &mut stdout_mock)
.unwrap();
// Verify last_line_completed is true.
assert!(
line_state.last_line_completed,
"last_line_completed should be true after newline-terminated data"
);
// Clear buffer for second call.
stdout_mock.buffer.lock().unwrap().clear();
// Second log line (ends with newline).
line_state
.print_data_and_flush(b"line 2\n", &mut stdout_mock)
.unwrap();
// Verify the stripped output has exactly 1 newline.
let stripped = stdout_mock.get_copy_of_buffer_as_string_strip_ansi();
let newline_count = stripped.matches('\n').count();
assert_eq!(
newline_count, 1,
"Expected exactly 1 newline in output, got {newline_count}. Stripped: {stripped:?}"
);
// Verify the escape sequence pattern doesn't have redundant CHA(1) after LF.
let decoded = describe_ansi_output(&stdout_mock.get_copy_of_buffer());
// After fix: should be [LF][CHA(1)] not [LF][CHA(1)][CHA(1)].
assert!(
!decoded.contains("[LF][CHA(1)][CHA(1)]"),
"Redundant CHA(1) after LF detected. Decoded: {decoded}"
);
}
/// Regression test: verify partial line writes still work correctly.
///
/// When data doesn't end with newline (e.g., manual `.flush()` call), the code
/// should still emit CHA(1) to ensure proper cursor positioning.
#[test]
fn test_print_data_partial_line_emits_cha() {
let mut line_state = LineState::new("> ".into(), (80, 24));
let mut stdout_mock = StdoutMock::default();
line_state.render_and_flush(&mut stdout_mock).unwrap();
stdout_mock.buffer.lock().unwrap().clear();
// Partial line (no newline at end).
line_state
.print_data_and_flush(b"partial", &mut stdout_mock)
.unwrap();
// Verify last_line_completed is false.
assert!(
!line_state.last_line_completed,
"last_line_completed should be false after non-newline data"
);
// Verify CHA(1) is emitted after the data for partial lines.
let decoded = describe_ansi_output(&stdout_mock.get_copy_of_buffer());
// For partial lines, we should see: data + CHA(1) + LF + CHA(1) + prompt.
assert!(
decoded.contains("partial[CHA(1)]"),
"CHA(1) should be emitted after partial line data. Decoded: {decoded}"
);
}
/// Test multiple segments in a single write (e.g., "line1\nline2\n").
///
/// In raw terminal mode, LF only moves the cursor down without returning to
/// column 1. Therefore, we need `CHA(1)` after each line segment to ensure
/// subsequent lines start at column 1. The only exception is the final segment
/// when it ends with newline - we skip `CHA(1)` there to avoid double `CHA(1)`
/// with the one emitted before `render_and_flush`.
#[test]
fn test_print_data_multiple_segments() {
let mut line_state = LineState::new("> ".into(), (80, 24));
let mut stdout_mock = StdoutMock::default();
line_state.render_and_flush(&mut stdout_mock).unwrap();
stdout_mock.buffer.lock().unwrap().clear();
// Multiple lines in single call.
line_state
.print_data_and_flush(b"line1\nline2\n", &mut stdout_mock)
.unwrap();
let decoded = describe_ansi_output(&stdout_mock.get_copy_of_buffer());
// After first LF, we need CHA(1) so line2 starts at column 1.
// After second LF, we skip CHA(1) since render_and_flush handles it.
// Expected pattern: line1[LF][CHA(1)]line2[LF][CHA(1)]> .
assert!(
decoded.contains("line1[LF][CHA(1)]line2[LF]"),
"First line should have [LF][CHA(1)] to return cursor to column 1. Decoded: {decoded}"
);
// Should NOT have double CHA(1) after the final LF.
assert!(
!decoded.contains("line2[LF][CHA(1)][CHA(1)]"),
"Should not have redundant CHA(1) after final LF. Decoded: {decoded}"
);
}
#[test]
fn test_exit_clears_line() {
let mut line_state = LineState::new("$ ".into(), (80, 24));
line_state.line = GCStringOwned::new("some content");
let mut stdout_mock = StdoutMock::default();
line_state.exit(&mut stdout_mock).unwrap();
// Line should be cleared.
assert!(line_state.line.is_empty());
}
#[test]
fn test_update_prompt_changes_prompt() {
let mut line_state = LineState::new("old> ".into(), (80, 24));
let mut stdout_mock = StdoutMock::default();
line_state.update_prompt("new> ", &mut stdout_mock).unwrap();
assert_eq!(line_state.prompt, "new> ");
}
#[test]
fn test_print_data_sets_last_line_completed() {
let mut line_state = LineState::new("$ ".into(), (80, 24));
let mut stdout_mock = StdoutMock::default();
// Data ending with newline.
line_state
.print_data_and_flush(b"hello\n", &mut stdout_mock)
.unwrap();
assert!(line_state.last_line_completed);
// Data not ending with newline.
line_state
.print_data_and_flush(b"world", &mut stdout_mock)
.unwrap();
assert!(!line_state.last_line_completed);
}
}