Make ScreenData track multiline prompt

Instead of pretending that prompt is always 1 line, track multiline
prompt in ScreenData.visible_prompt_lines and ScreenData.line_datas as
empty lines.

This enables:
- Trimming part of the prompt that leaves the viewport.
- Removing of the old hack needed for locating first prompt line.
- Fixing #11875.

Part of #11911
This commit is contained in:
kerty
2025-10-15 13:41:27 +03:00
committed by Johannes Altmanninger
parent 71b619bab0
commit 606802daaf
7 changed files with 163 additions and 121 deletions

View File

@@ -4,6 +4,7 @@ fish ?.?.? (released ???)
Notable improvements and fixes
------------------------------
- New Taiwanese Chinese translation.
- Fixed not properly clearing lines when a :ref:`transient prompt <transient-prompt>` contains more lines than the final prompt (:issue:`11875`).
Deprecations and removed features
---------------------------------
@@ -11,6 +12,7 @@ Deprecations and removed features
Interactive improvements
------------------------
- :doc:`fish_config prompt {choose,save} <cmds/fish_config>` have been taught to reset :doc:`fish_mode_prompt <cmds/fish_mode_prompt>` in addition to the other prompt functions (:issue:`11937`).
- Fish now hides the portion of a multiline prompt that is scrolled out of view due to a huge command line. This prevents duplicate lines after repainting with partially visible prompt (:issue:`11911`).
Scripting improvements
----------------------

View File

@@ -4741,10 +4741,6 @@ fn exec_prompt(&mut self, full_prompt: bool, final_prompt: bool) {
self.left_prompt_buff =
join_strings(&self.exec_prompt_cmd(prompt_cmd, final_prompt), '\n');
if final_prompt {
self.screen.multiline_prompt_hack();
}
}
// Don't execute the right prompt if it is undefined fish_right_prompt

View File

@@ -154,7 +154,12 @@ pub struct ScreenData {
/// The width of the screen once we have rendered.
screen_width: Option<usize>,
/// Virtual cursor position used for writing to `line_datas`,
/// and also the viewport final cursor position.
cursor: Cursor,
/// Number of prompt lines rendered on the screen.
visible_prompt_lines: usize,
}
impl ScreenData {
@@ -349,9 +354,11 @@ struct ScrolledCursor {
// Clear the desired screen and set its width.
self.desired.screen_width = Some(screen_width);
self.desired.clear_lines();
self.desired.cursor.x = 0;
self.desired.cursor.y = 0;
self.desired.cursor.y = layout.left_prompt_lines - 1;
self.desired.clear_lines();
self.desired.resize(self.desired.cursor.y);
// Append spaces for the left prompt.
let prompt_offset = if pager.search_field_shown {
@@ -454,7 +461,7 @@ struct ScrolledCursor {
self.desired.add_line();
}
let full_line_count = self.desired.cursor.y
let full_line_count = self.desired.cursor.y + 1
- if self.desired.cursor.x == 0
&& self.desired.cursor.y.checked_sub(1).is_some_and(|y| {
self.desired.line_datas[y].is_soft_wrapped
@@ -467,8 +474,7 @@ struct ScrolledCursor {
1
} else {
0
}
+ calc_prompt_lines(&layout.left_prompt);
};
let pager_available_height = std::cmp::max(
1,
curr_termsize
@@ -514,30 +520,21 @@ struct ScrolledCursor {
self.desired.append_lines(&page_rendering.screen_data);
self.scrolled = scrolled_cursor.scroll_amount != 0;
self.desired.visible_prompt_lines =
layout
.left_prompt_lines
.saturating_sub(if is_final_rendering {
0
} else {
scrolled_cursor.scroll_amount
});
self.with_buffered_output(|zelf| {
zelf.update(
vars,
&layout.left_prompt,
&layout.right_prompt,
is_final_rendering,
)
zelf.update(vars, &layout.left_prompt, &layout.right_prompt)
});
self.save_status();
}
pub fn multiline_prompt_hack(&mut self) {
// If the prompt is multi-line, we need to move up to the prompt's initial line. We do this
// by lying to ourselves and claiming that we're really below what we consider "line 0"
// (which is the last line of the prompt). This will cause us to move up to try to get back
// to line 0, but really we're getting back to the initial line of the prompt.
let prompt_line_count = self
.actual_left_prompt
.as_ref()
.map_or(1, |p| calc_prompt_lines(p));
self.actual.cursor.y += prompt_line_count.checked_sub(1).unwrap();
}
/// Resets the screen buffer's internal knowledge about the contents of the screen,
/// optionally repainting the prompt as well.
/// This function assumes that the current line is still valid.
@@ -549,7 +546,6 @@ pub fn reset_line(&mut self, repaint_prompt: bool /* = false */) {
std::cmp::max(self.actual_lines_before_reset, self.actual.line_count());
if repaint_prompt {
self.multiline_prompt_hack();
self.actual_left_prompt = None;
self.need_clear_screen = true;
}
@@ -564,24 +560,7 @@ pub fn reset_line(&mut self, repaint_prompt: bool /* = false */) {
}
pub fn push_to_scrollback(&mut self, cursor_y: usize) {
let prompt_y = self.command_line_y_given_cursor_y(cursor_y);
let trailing_prompt_lines = self
.actual_left_prompt
.as_ref()
.map_or(0, |p| calc_prompt_lines(p) - 1);
let lines_to_scroll = prompt_y
.checked_sub(trailing_prompt_lines)
.unwrap_or_else(|| {
FLOG!(
reader,
"Number of trailing prompt lines prompt lines",
trailing_prompt_lines,
"exceeds prompt's y",
prompt_y,
"inferred from reported cursor position",
);
0
});
let lines_to_scroll = self.command_line_y_given_cursor_y(cursor_y);
if lines_to_scroll == 0 {
return;
}
@@ -625,8 +604,9 @@ pub fn offset_in_cmdline_given_cursor(
"inferred from reported cursor position",
);
0
});
let y = y.min(self.actual.line_count() - 1);
})
.max(self.actual.visible_prompt_lines - 1)
.min(self.actual.line_count() - 1);
let Some(viewport_prompt_x) = viewport_cursor.x.checked_sub(self.actual.cursor.x) else {
FLOGF!(
reader,
@@ -1044,13 +1024,7 @@ fn with_buffered_output(&mut self, f: impl FnOnce(&mut Self)) {
}
/// Update the screen to match the desired output.
fn update(
&mut self,
vars: &dyn Environment,
left_prompt: &wstr,
right_prompt: &wstr,
is_final_rendering: bool,
) {
fn update(&mut self, vars: &dyn Environment, left_prompt: &wstr, right_prompt: &wstr) {
// Helper function to set a resolved color, using the caching resolver.
let mut color_resolver = HighlightColorResolver::new();
let mut set_color = |zelf: &mut Self, c| {
@@ -1101,46 +1075,57 @@ fn update(
need_clear_screen = true;
}
// Output the left prompt if it has changed.
if self.scrolled && !is_final_rendering {
self.r#move(0, 0);
self.write_command(ClearToEndOfLine);
self.actual_left_prompt = None;
self.actual.cursor.x = 0;
} else {
let prompt_changed = self
let is_prompt_visible = self.desired.visible_prompt_lines > 0;
let prompt_last_line = self.desired.visible_prompt_lines.saturating_sub(1);
let prompt_changed = is_prompt_visible
&& (self
.actual_left_prompt
.as_ref()
.is_none_or(|p| p != left_prompt)
|| (self.scrolled && is_final_rendering);
|| self.actual.visible_prompt_lines != self.desired.visible_prompt_lines);
let prompt_last_line_should_wrap =
Some(prompt_last_line_width) == screen_width && self.should_wrap(0);
let prompt_last_line_screen_wide =
is_prompt_visible && Some(prompt_last_line_width) == screen_width;
let prompt_last_line_should_wrap =
prompt_last_line_screen_wide && self.should_wrap(prompt_last_line);
if prompt_changed || prompt_last_line_should_wrap {
// Output the left prompt if it has changed or we need to refresh softwrap.
if prompt_changed || prompt_last_line_should_wrap {
let mut start;
if prompt_changed {
self.r#move(0, 0);
let mut start = 0;
if left_prompt_layout.line_starts.len() <= 1 {
let prompt_first_visible_line =
left_prompt_layout.line_starts.len() - self.desired.visible_prompt_lines;
start = left_prompt_layout.line_starts[prompt_first_visible_line];
if self.desired.visible_prompt_lines == 1 {
self.write_command(Osc133PromptStart);
}
if prompt_changed {
for (i, &next_line) in left_prompt_layout.line_starts[1..].iter().enumerate() {
self.write_command(ClearToEndOfLine);
if i == 0 {
self.write_command(Osc133PromptStart);
}
self.write_str(&left_prompt[start..next_line]);
start = next_line;
for (i, &next_line) in left_prompt_layout.line_starts
[prompt_first_visible_line + 1..]
.iter()
.enumerate()
{
self.write_command(ClearToEndOfLine);
if i == 0 {
self.write_command(Osc133PromptStart);
}
} else {
start = *left_prompt_layout.line_starts.last().unwrap();
}
self.write_str(&left_prompt[start..]);
self.actual_left_prompt = Some(left_prompt.to_owned());
self.actual.cursor.x = prompt_last_line_width;
if prompt_last_line_should_wrap {
self.soft_wrap_location = Some(Cursor { x: 0, y: 1 });
self.write_str(&left_prompt[start..next_line]);
start = next_line;
}
} else {
self.r#move(0, prompt_last_line);
start = *left_prompt_layout.line_starts.last().unwrap();
}
self.write_str(&left_prompt[start..]);
self.actual_left_prompt = Some(left_prompt.to_owned());
self.actual.cursor.x = prompt_last_line_width;
self.actual.cursor.y = prompt_last_line;
if prompt_last_line_should_wrap {
self.soft_wrap_location = Some(Cursor {
x: 0,
y: prompt_last_line + 1,
});
}
}
@@ -1152,9 +1137,10 @@ fn s_line(zelf: &Screen, i: usize) -> &Line {
}
// Output all lines.
for i in 0..self.desired.line_count() {
let commandline_start = prompt_last_line + if prompt_last_line_screen_wide { 1 } else { 0 };
for i in commandline_start..self.desired.line_count() {
self.actual.create_line(i);
let is_prompt_line = i == 0 && !self.scrolled;
let is_prompt_line = is_prompt_visible && i == prompt_last_line;
let start_pos = if is_prompt_line {
prompt_last_line_width
} else {
@@ -1174,17 +1160,21 @@ fn s_line(zelf: &Screen, i: usize) -> &Line {
// Note that skip_remaining is a width, not a character count.
let mut skip_remaining = start_pos;
let shared_prefix = if self.scrolled {
let previously_prompt_line = self.actual.visible_prompt_lines > i + 1;
let shared_prefix = if self.scrolled || previously_prompt_line {
0
} else {
line_shared_prefix(o_line(self, i), s_line(self, i))
};
let mut skip_prefix = shared_prefix;
if shared_prefix < o_line(self, i).indentation {
if o_line(self, i).indentation > s_line(self, i).indentation && !has_cleared_screen
if shared_prefix < o_line(self, i).indentation || previously_prompt_line {
if !has_cleared_screen
&& (o_line(self, i).indentation > s_line(self, i).indentation
|| previously_prompt_line)
{
set_color(self, HighlightSpec::new());
self.r#move(0, i);
self.r#move(start_pos, i);
self.write_command(if should_clear_screen_this_line {
ClearToEndOfScreen
} else {
@@ -1296,7 +1286,7 @@ fn s_line(zelf: &Screen, i: usize) -> &Line {
if is_prompt_line && right_prompt_width > 0 {
// Move the cursor to the beginning of the line first to be independent of the width.
// This helps prevent staircase effects if fish and the terminal disagree.
self.r#move(0, 0);
self.r#move(0, i);
self.r#move(screen_width.unwrap() - right_prompt_width, i);
set_color(self, HighlightSpec::new());
self.write_str(right_prompt);
@@ -1851,22 +1841,6 @@ fn truncate_run(
*width = curr_width;
}
fn calc_prompt_lines(prompt: &wstr) -> usize {
// Hack for the common case where there's no newline at all. I don't know if a newline can
// appear in an escape sequence, so if we detect a newline we have to defer to
// calc_prompt_width_and_lines.
let mut result = 1;
if prompt.chars().any(|c| matches!(c, '\n' | '\x0C')) {
result = LAYOUT_CACHE_SHARED
.lock()
.unwrap()
.calc_prompt_layout(prompt, None, usize::MAX)
.line_starts
.len();
}
result
}
/// Returns the length of the "shared prefix" of the two lines, which is the run of matching text
/// and colors. If the prefix ends on a combining character, do not include the previous character
/// in the prefix.
@@ -1932,6 +1906,8 @@ pub(crate) fn only_grayscale() -> bool {
pub(crate) struct ScreenLayout {
// The left prompt that we're going to use.
pub(crate) left_prompt: WString,
// How many lines in the left prompt.
pub(crate) left_prompt_lines: usize,
// How much space to leave for it.
pub(crate) left_prompt_space: usize,
// The right prompt.
@@ -2016,6 +1992,7 @@ pub(crate) fn compute_layout(
// Always visible.
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
result.left_prompt_lines = left_prompt_layout.line_starts.len();
// Hide the right prompt if it doesn't fit on the first line.
if left_prompt_width + first_command_line_width + right_prompt_width < screen_width {

View File

@@ -277,6 +277,7 @@ macro_rules! validate {
ScreenLayout {
left_prompt: L!($left_prompt).to_owned(),
left_prompt_space: $left_prompt_space,
left_prompt_lines: 1,
right_prompt: L!($right_prompt).to_owned(),
autosuggestion: L!($autosuggestion).to_owned(),
}

View File

@@ -42,29 +42,40 @@ isolated-tmux capture-pane -p
isolated-tmux send-keys C-c
tmux-sleep
isolated-tmux send-keys C-l 'commandline -i ": \'$(seq $LINES)" A B "C\'"' Enter Enter
isolated-tmux send-keys C-l '
function fish_right_prompt
echo right-prompt
end
' 'commandline -i ": \'$(seq (math $LINES \* 2))\'"' Enter Enter
tmux-sleep
isolated-tmux capture-pane -p
isolated-tmux capture-pane -p -S -12
# CHECK: prompt 4> commandline -i ": '$(seq (math $LINES \* 2))'" right-prompt
# CHECK: prompt 5> : '1 right-prompt
# CHECK: 2
# CHECK: 3
# CHECK: 4
# CHECK: 5
# CHECK: 6
# CHECK: 7
# CHECK: 8
# CHECK: 9
# CHECK: 10
# CHECK: A
# CHECK: B
# CHECK: C'
# CHECK: prompt 5>
# CHECK: 11
# CHECK: 12
# CHECK: 13
# CHECK: 14
# CHECK: 15
# CHECK: 16
# CHECK: 17
# CHECK: 18
# CHECK: 19
# CHECK: 20'
# CHECK: prompt 6> right-prompt
# Soft-wrapped commandline with omitted right prompt.
isolated-tmux send-keys C-q '
function fish_right_prompt
echo right-prompt
end
commandline -i "echo $(printf %0"$COLUMNS"d)"
' Enter
isolated-tmux send-keys 'commandline -i "echo $(printf %0"$COLUMNS"d)"' Enter C-l Enter
tmux-sleep
isolated-tmux capture-pane -p | sed 1,5d
isolated-tmux capture-pane -p
# CHECK: prompt {{\d+}}> echo 00000000000000000000000000000000000000000000000000000000000000000
# CHECK: 000000000000000
# CHECK: 00000000000000000000000000000000000000000000000000000000000000000000000000000000

View File

@@ -61,3 +61,16 @@ isolated-tmux capture-pane -p
# CHECK: Hello World
# CHECK: prompt-line-1
# CHECK: prompt-line-2>
# Test that transient prompt does not break the prompt.
isolated-tmux send-keys C-l "set fish_transient_prompt 1" Enter : Enter Enter
tmux-sleep
isolated-tmux capture-pane -p
# CHECK: prompt-line-1
# CHECK: prompt-line-2> set fish_transient_prompt 1
# CHECK: prompt-line-1
# CHECK: prompt-line-2> :
# CHECK: prompt-line-1
# CHECK: prompt-line-2>
# CHECK: prompt-line-1
# CHECK: prompt-line-2>

View File

@@ -46,3 +46,45 @@ isolated-tmux capture-pane -p
# CHECK: …<----------------------------------------------two-last-characters-rendered->!!
# CHECK: test "
# CHECK: indent"
isolated-tmux send-keys C-c '
function fish_prompt
string repeat (math $COLUMNS) x
end
' C-l 'echo hello'
tmux-sleep
isolated-tmux capture-pane -p
# CHECK: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# CHECK: echo hello
isolated-tmux send-keys C-c '
function fish_prompt
seq (math $LINES + 1)
end
function fish_right_prompt
echo test
end
' Enter
tmux-sleep
isolated-tmux capture-pane -p -S -11
# CHECK: 1
# CHECK: 2
# CHECK: 3
# CHECK: 4
# CHECK: 5
# CHECK: 6
# CHECK: 7
# CHECK: 8
# CHECK: 9
# CHECK: 10
# CHECK: 11 test
# CHECK: 2
# CHECK: 3
# CHECK: 4
# CHECK: 5
# CHECK: 6
# CHECK: 7
# CHECK: 8
# CHECK: 9
# CHECK: 10
# CHECK: 11 test