Fixes #4870 #4717 #3260 #4431 #2718 #4898 #5036 - Fix the chat composer “phantom space” bug that appeared when backspacing CJK (and other double-width) characters after the composer got a uniform background in 43b63ccae89c…. - Pull diff_buffers’s clear-to-end logic forward to iterate by display width, so wide graphemes are counted correctly when computing the trailing column. - Keep modifier-aware detection so styled cells are still flushed, and add a regression test (diff_buffers_clear_to_end_starts_after_wide_char) that covers the CJK deletion scenario. --------- Co-authored-by: Josh McKinney <joshka@openai.com>
650 lines
24 KiB
Rust
650 lines
24 KiB
Rust
// This is derived from `ratatui::Terminal`, which is licensed under the following terms:
|
||
//
|
||
// The MIT License (MIT)
|
||
// Copyright (c) 2016-2022 Florian Dehau
|
||
// Copyright (c) 2023-2025 The Ratatui Developers
|
||
//
|
||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||
// of this software and associated documentation files (the "Software"), to deal
|
||
// in the Software without restriction, including without limitation the rights
|
||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||
// copies of the Software, and to permit persons to whom the Software is
|
||
// furnished to do so, subject to the following conditions:
|
||
//
|
||
// The above copyright notice and this permission notice shall be included in all
|
||
// copies or substantial portions of the Software.
|
||
//
|
||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||
// SOFTWARE.
|
||
use std::io;
|
||
use std::io::Write;
|
||
|
||
use crossterm::cursor::MoveTo;
|
||
use crossterm::queue;
|
||
use crossterm::style::Colors;
|
||
use crossterm::style::Print;
|
||
use crossterm::style::SetAttribute;
|
||
use crossterm::style::SetBackgroundColor;
|
||
use crossterm::style::SetColors;
|
||
use crossterm::style::SetForegroundColor;
|
||
use crossterm::terminal::Clear;
|
||
use ratatui::backend::Backend;
|
||
use ratatui::backend::ClearType;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Position;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::layout::Size;
|
||
use ratatui::style::Color;
|
||
use ratatui::style::Modifier;
|
||
use ratatui::widgets::WidgetRef;
|
||
|
||
#[derive(Debug, Hash)]
|
||
pub struct Frame<'a> {
|
||
/// Where should the cursor be after drawing this frame?
|
||
///
|
||
/// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x,
|
||
/// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
|
||
pub(crate) cursor_position: Option<Position>,
|
||
|
||
/// The area of the viewport
|
||
pub(crate) viewport_area: Rect,
|
||
|
||
/// The buffer that is used to draw the current frame
|
||
pub(crate) buffer: &'a mut Buffer,
|
||
}
|
||
|
||
impl Frame<'_> {
|
||
/// The area of the current frame
|
||
///
|
||
/// This is guaranteed not to change during rendering, so may be called multiple times.
|
||
///
|
||
/// If your app listens for a resize event from the backend, it should ignore the values from
|
||
/// the event for any calculations that are used to render the current frame and use this value
|
||
/// instead as this is the area of the buffer that is used to render the current frame.
|
||
pub const fn area(&self) -> Rect {
|
||
self.viewport_area
|
||
}
|
||
|
||
/// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`].
|
||
///
|
||
/// Usually the area argument is the size of the current frame or a sub-area of the current
|
||
/// frame (which can be obtained using [`Layout`] to split the total area).
|
||
#[allow(clippy::needless_pass_by_value)]
|
||
pub fn render_widget_ref<W: WidgetRef>(&mut self, widget: W, area: Rect) {
|
||
widget.render_ref(area, self.buffer);
|
||
}
|
||
|
||
/// After drawing this frame, make the cursor visible and put it at the specified (x, y)
|
||
/// coordinates. If this method is not called, the cursor will be hidden.
|
||
///
|
||
/// Note that this will interfere with calls to [`Terminal::hide_cursor`],
|
||
/// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and
|
||
/// stick with it.
|
||
///
|
||
/// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor
|
||
/// [`Terminal::show_cursor`]: crate::Terminal::show_cursor
|
||
/// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position
|
||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) {
|
||
self.cursor_position = Some(position.into());
|
||
}
|
||
|
||
/// Gets the buffer that this `Frame` draws into as a mutable reference.
|
||
pub fn buffer_mut(&mut self) -> &mut Buffer {
|
||
self.buffer
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||
pub struct Terminal<B>
|
||
where
|
||
B: Backend + Write,
|
||
{
|
||
/// The backend used to interface with the terminal
|
||
backend: B,
|
||
/// Holds the results of the current and previous draw calls. The two are compared at the end
|
||
/// of each draw pass to output the necessary updates to the terminal
|
||
buffers: [Buffer; 2],
|
||
/// Index of the current buffer in the previous array
|
||
current: usize,
|
||
/// Whether the cursor is currently hidden
|
||
pub hidden_cursor: bool,
|
||
/// Area of the viewport
|
||
pub viewport_area: Rect,
|
||
/// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
|
||
pub last_known_screen_size: Size,
|
||
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
|
||
/// and the terminal resized.
|
||
pub last_known_cursor_pos: Position,
|
||
|
||
use_custom_flush: bool,
|
||
}
|
||
|
||
impl<B> Drop for Terminal<B>
|
||
where
|
||
B: Backend,
|
||
B: Write,
|
||
{
|
||
#[allow(clippy::print_stderr)]
|
||
fn drop(&mut self) {
|
||
// Attempt to restore the cursor state
|
||
if self.hidden_cursor
|
||
&& let Err(err) = self.show_cursor()
|
||
{
|
||
eprintln!("Failed to show the cursor: {err}");
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<B> Terminal<B>
|
||
where
|
||
B: Backend,
|
||
B: Write,
|
||
{
|
||
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||
pub fn with_options(mut backend: B) -> io::Result<Self> {
|
||
let screen_size = backend.size()?;
|
||
let cursor_pos = backend.get_cursor_position()?;
|
||
Ok(Self {
|
||
backend,
|
||
buffers: [
|
||
Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||
Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||
],
|
||
current: 0,
|
||
hidden_cursor: false,
|
||
viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
|
||
last_known_screen_size: screen_size,
|
||
last_known_cursor_pos: cursor_pos,
|
||
use_custom_flush: true,
|
||
})
|
||
}
|
||
|
||
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||
pub fn get_frame(&mut self) -> Frame<'_> {
|
||
Frame {
|
||
cursor_position: None,
|
||
viewport_area: self.viewport_area,
|
||
buffer: self.current_buffer_mut(),
|
||
}
|
||
}
|
||
|
||
/// Gets the current buffer as a mutable reference.
|
||
pub fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||
&mut self.buffers[self.current]
|
||
}
|
||
|
||
/// Gets the backend
|
||
pub const fn backend(&self) -> &B {
|
||
&self.backend
|
||
}
|
||
|
||
/// Gets the backend as a mutable reference
|
||
pub fn backend_mut(&mut self) -> &mut B {
|
||
&mut self.backend
|
||
}
|
||
|
||
/// Obtains a difference between the previous and the current buffer and passes it to the
|
||
/// current backend for drawing.
|
||
pub fn flush(&mut self) -> io::Result<()> {
|
||
let previous_buffer = &self.buffers[1 - self.current];
|
||
let current_buffer = &self.buffers[self.current];
|
||
|
||
if self.use_custom_flush {
|
||
let updates = diff_buffers(previous_buffer, current_buffer);
|
||
if let Some(DrawCommand::Put { x, y, .. }) = updates
|
||
.iter()
|
||
.rev()
|
||
.find(|cmd| matches!(cmd, DrawCommand::Put { .. }))
|
||
{
|
||
self.last_known_cursor_pos = Position { x: *x, y: *y };
|
||
}
|
||
draw(&mut self.backend, updates.into_iter())
|
||
} else {
|
||
let updates = previous_buffer.diff(current_buffer);
|
||
if let Some((x, y, _)) = updates.last() {
|
||
self.last_known_cursor_pos = Position { x: *x, y: *y };
|
||
}
|
||
self.backend.draw(updates.into_iter())
|
||
}
|
||
}
|
||
|
||
/// Updates the Terminal so that internal buffers match the requested area.
|
||
///
|
||
/// Requested area will be saved to remain consistent when rendering. This leads to a full clear
|
||
/// of the screen.
|
||
pub fn resize(&mut self, screen_size: Size) -> io::Result<()> {
|
||
self.last_known_screen_size = screen_size;
|
||
Ok(())
|
||
}
|
||
|
||
/// Sets the viewport area.
|
||
pub fn set_viewport_area(&mut self, area: Rect) {
|
||
self.buffers[self.current].resize(area);
|
||
self.buffers[1 - self.current].resize(area);
|
||
self.viewport_area = area;
|
||
}
|
||
|
||
/// Queries the backend for size and resizes if it doesn't match the previous size.
|
||
pub fn autoresize(&mut self) -> io::Result<()> {
|
||
let screen_size = self.size()?;
|
||
if screen_size != self.last_known_screen_size {
|
||
self.resize(screen_size)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Draws a single frame to the terminal.
|
||
///
|
||
/// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`].
|
||
///
|
||
/// If the render callback passed to this method can fail, use [`try_draw`] instead.
|
||
///
|
||
/// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
|
||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||
///
|
||
/// [`try_draw`]: Terminal::try_draw
|
||
///
|
||
/// This method will:
|
||
///
|
||
/// - autoresize the terminal if necessary
|
||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||
/// - flush the current internal state by copying the current buffer to the backend
|
||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||
///
|
||
/// The render callback should fully render the entire frame when called, including areas that
|
||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||
/// previous frame to determine what has changed, and only the changes are written to the
|
||
/// terminal. If the render callback does not fully render the frame, the terminal will not be
|
||
/// in a consistent state.
|
||
pub fn draw<F>(&mut self, render_callback: F) -> io::Result<()>
|
||
where
|
||
F: FnOnce(&mut Frame),
|
||
{
|
||
self.try_draw(|frame| {
|
||
render_callback(frame);
|
||
io::Result::Ok(())
|
||
})
|
||
}
|
||
|
||
/// Tries to draw a single frame to the terminal.
|
||
///
|
||
/// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
|
||
/// [`Result::Err`] containing the [`std::io::Error`] that caused the failure.
|
||
///
|
||
/// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
|
||
/// closure that returns a `Result` instead of nothing.
|
||
///
|
||
/// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
|
||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||
///
|
||
/// [`draw`]: Terminal::draw
|
||
///
|
||
/// This method will:
|
||
///
|
||
/// - autoresize the terminal if necessary
|
||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||
/// - flush the current internal state by copying the current buffer to the backend
|
||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||
/// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||
///
|
||
/// The render callback passed to `try_draw` can return any [`Result`] with an error type that
|
||
/// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible
|
||
/// to use the `?` operator to propagate errors that occur during rendering. If the render
|
||
/// callback returns an error, the error will be returned from `try_draw` as an
|
||
/// [`std::io::Error`] and the terminal will not be updated.
|
||
///
|
||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||
/// purposes, but it is often not used in regular applicationss.
|
||
///
|
||
/// The render callback should fully render the entire frame when called, including areas that
|
||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||
/// previous frame to determine what has changed, and only the changes are written to the
|
||
/// terminal. If the render function does not fully render the frame, the terminal will not be
|
||
/// in a consistent state.
|
||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<()>
|
||
where
|
||
F: FnOnce(&mut Frame) -> Result<(), E>,
|
||
E: Into<io::Error>,
|
||
{
|
||
// Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
|
||
// and the terminal (if growing), which may OOB.
|
||
self.autoresize()?;
|
||
|
||
let mut frame = self.get_frame();
|
||
|
||
render_callback(&mut frame).map_err(Into::into)?;
|
||
|
||
// We can't change the cursor position right away because we have to flush the frame to
|
||
// stdout first. But we also can't keep the frame around, since it holds a &mut to
|
||
// Buffer. Thus, we're taking the important data out of the Frame and dropping it.
|
||
let cursor_position = frame.cursor_position;
|
||
|
||
// Draw to stdout
|
||
self.flush()?;
|
||
|
||
match cursor_position {
|
||
None => self.hide_cursor()?,
|
||
Some(position) => {
|
||
self.show_cursor()?;
|
||
self.set_cursor_position(position)?;
|
||
}
|
||
}
|
||
|
||
self.swap_buffers();
|
||
|
||
ratatui::backend::Backend::flush(&mut self.backend)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Hides the cursor.
|
||
pub fn hide_cursor(&mut self) -> io::Result<()> {
|
||
self.backend.hide_cursor()?;
|
||
self.hidden_cursor = true;
|
||
Ok(())
|
||
}
|
||
|
||
/// Shows the cursor.
|
||
pub fn show_cursor(&mut self) -> io::Result<()> {
|
||
self.backend.show_cursor()?;
|
||
self.hidden_cursor = false;
|
||
Ok(())
|
||
}
|
||
|
||
/// Gets the current cursor position.
|
||
///
|
||
/// This is the position of the cursor after the last draw call.
|
||
#[allow(dead_code)]
|
||
pub fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||
self.backend.get_cursor_position()
|
||
}
|
||
|
||
/// Sets the cursor position.
|
||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||
let position = position.into();
|
||
self.backend.set_cursor_position(position)?;
|
||
self.last_known_cursor_pos = position;
|
||
Ok(())
|
||
}
|
||
|
||
/// Clear the terminal and force a full redraw on the next draw call.
|
||
pub fn clear(&mut self) -> io::Result<()> {
|
||
if self.viewport_area.is_empty() {
|
||
return Ok(());
|
||
}
|
||
self.backend
|
||
.set_cursor_position(self.viewport_area.as_position())?;
|
||
self.backend.clear_region(ClearType::AfterCursor)?;
|
||
// Reset the back buffer to make sure the next update will redraw everything.
|
||
self.buffers[1 - self.current].reset();
|
||
Ok(())
|
||
}
|
||
|
||
/// Clears the inactive buffer and swaps it with the current buffer
|
||
pub fn swap_buffers(&mut self) {
|
||
self.buffers[1 - self.current].reset();
|
||
self.current = 1 - self.current;
|
||
}
|
||
|
||
/// Queries the real size of the backend.
|
||
pub fn size(&self) -> io::Result<Size> {
|
||
self.backend.size()
|
||
}
|
||
}
|
||
|
||
use ratatui::buffer::Cell;
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
#[derive(Debug)]
|
||
enum DrawCommand<'a> {
|
||
Put { x: u16, y: u16, cell: &'a Cell },
|
||
ClearToEnd { x: u16, y: u16, bg: Color },
|
||
}
|
||
|
||
fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec<DrawCommand<'a>> {
|
||
let previous_buffer = &a.content;
|
||
let next_buffer = &b.content;
|
||
|
||
let mut updates = vec![];
|
||
let mut last_nonblank_columns = vec![0; a.area.height as usize];
|
||
for y in 0..a.area.height {
|
||
let row_start = y as usize * a.area.width as usize;
|
||
let row_end = row_start + a.area.width as usize;
|
||
let row = &next_buffer[row_start..row_end];
|
||
let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset);
|
||
|
||
// Scan the row to find the rightmost column that still matters: any non-space glyph,
|
||
// any cell whose bg differs from the row’s trailing bg, or any cell with modifiers.
|
||
// Multi-width glyphs extend that region through their full displayed width.
|
||
// After that point the rest of the row can be cleared with a single ClearToEnd, a perf win
|
||
// versus emitting multiple space Put commands.
|
||
let mut last_nonblank_column = 0usize;
|
||
let mut column = 0usize;
|
||
while column < row.len() {
|
||
let cell = &row[column];
|
||
let width = cell.symbol().width();
|
||
if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() {
|
||
last_nonblank_column = column + (width.saturating_sub(1));
|
||
}
|
||
column += width.max(1); // treat zero-width symbols as width 1
|
||
}
|
||
|
||
if last_nonblank_column + 1 < row.len() {
|
||
let (x, y) = a.pos_of(row_start + last_nonblank_column + 1);
|
||
updates.push(DrawCommand::ClearToEnd { x, y, bg });
|
||
}
|
||
|
||
last_nonblank_columns[y as usize] = last_nonblank_column as u16;
|
||
}
|
||
|
||
// Cells invalidated by drawing/replacing preceding multi-width characters:
|
||
let mut invalidated: usize = 0;
|
||
// Cells from the current buffer to skip due to preceding multi-width characters taking
|
||
// their place (the skipped cells should be blank anyway), or due to per-cell-skipping:
|
||
let mut to_skip: usize = 0;
|
||
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
|
||
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
|
||
let (x, y) = a.pos_of(i);
|
||
let row = i / a.area.width as usize;
|
||
if x <= last_nonblank_columns[row] {
|
||
updates.push(DrawCommand::Put {
|
||
x,
|
||
y,
|
||
cell: &next_buffer[i],
|
||
});
|
||
}
|
||
}
|
||
|
||
to_skip = current.symbol().width().saturating_sub(1);
|
||
|
||
let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width());
|
||
invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1);
|
||
}
|
||
updates
|
||
}
|
||
|
||
fn draw<'a, I>(writer: &mut impl Write, commands: I) -> io::Result<()>
|
||
where
|
||
I: Iterator<Item = DrawCommand<'a>>,
|
||
{
|
||
let mut fg = Color::Reset;
|
||
let mut bg = Color::Reset;
|
||
let mut modifier = Modifier::empty();
|
||
let mut last_pos: Option<Position> = None;
|
||
for command in commands {
|
||
let (x, y) = match command {
|
||
DrawCommand::Put { x, y, .. } => (x, y),
|
||
DrawCommand::ClearToEnd { x, y, .. } => (x, y),
|
||
};
|
||
// Move the cursor if the previous location was not (x - 1, y)
|
||
if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) {
|
||
queue!(writer, MoveTo(x, y))?;
|
||
}
|
||
last_pos = Some(Position { x, y });
|
||
match command {
|
||
DrawCommand::Put { cell, .. } => {
|
||
if cell.modifier != modifier {
|
||
let diff = ModifierDiff {
|
||
from: modifier,
|
||
to: cell.modifier,
|
||
};
|
||
diff.queue(writer)?;
|
||
modifier = cell.modifier;
|
||
}
|
||
if cell.fg != fg || cell.bg != bg {
|
||
queue!(
|
||
writer,
|
||
SetColors(Colors::new(cell.fg.into(), cell.bg.into()))
|
||
)?;
|
||
fg = cell.fg;
|
||
bg = cell.bg;
|
||
}
|
||
|
||
queue!(writer, Print(cell.symbol()))?;
|
||
}
|
||
DrawCommand::ClearToEnd { bg: clear_bg, .. } => {
|
||
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?;
|
||
modifier = Modifier::empty();
|
||
queue!(writer, SetBackgroundColor(clear_bg.into()))?;
|
||
bg = clear_bg;
|
||
queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?;
|
||
}
|
||
}
|
||
}
|
||
|
||
queue!(
|
||
writer,
|
||
SetForegroundColor(crossterm::style::Color::Reset),
|
||
SetBackgroundColor(crossterm::style::Color::Reset),
|
||
SetAttribute(crossterm::style::Attribute::Reset),
|
||
)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier`
|
||
/// values. This is useful when updating the terminal display, as it allows for more
|
||
/// efficient updates by only sending the necessary changes.
|
||
struct ModifierDiff {
|
||
pub from: Modifier,
|
||
pub to: Modifier,
|
||
}
|
||
|
||
impl ModifierDiff {
|
||
fn queue<W: io::Write>(self, w: &mut W) -> io::Result<()> {
|
||
use crossterm::style::Attribute as CAttribute;
|
||
let removed = self.from - self.to;
|
||
if removed.contains(Modifier::REVERSED) {
|
||
queue!(w, SetAttribute(CAttribute::NoReverse))?;
|
||
}
|
||
if removed.contains(Modifier::BOLD) {
|
||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
||
if self.to.contains(Modifier::DIM) {
|
||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
||
}
|
||
}
|
||
if removed.contains(Modifier::ITALIC) {
|
||
queue!(w, SetAttribute(CAttribute::NoItalic))?;
|
||
}
|
||
if removed.contains(Modifier::UNDERLINED) {
|
||
queue!(w, SetAttribute(CAttribute::NoUnderline))?;
|
||
}
|
||
if removed.contains(Modifier::DIM) {
|
||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
||
}
|
||
if removed.contains(Modifier::CROSSED_OUT) {
|
||
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
|
||
}
|
||
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
|
||
queue!(w, SetAttribute(CAttribute::NoBlink))?;
|
||
}
|
||
|
||
let added = self.to - self.from;
|
||
if added.contains(Modifier::REVERSED) {
|
||
queue!(w, SetAttribute(CAttribute::Reverse))?;
|
||
}
|
||
if added.contains(Modifier::BOLD) {
|
||
queue!(w, SetAttribute(CAttribute::Bold))?;
|
||
}
|
||
if added.contains(Modifier::ITALIC) {
|
||
queue!(w, SetAttribute(CAttribute::Italic))?;
|
||
}
|
||
if added.contains(Modifier::UNDERLINED) {
|
||
queue!(w, SetAttribute(CAttribute::Underlined))?;
|
||
}
|
||
if added.contains(Modifier::DIM) {
|
||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
||
}
|
||
if added.contains(Modifier::CROSSED_OUT) {
|
||
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
|
||
}
|
||
if added.contains(Modifier::SLOW_BLINK) {
|
||
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
|
||
}
|
||
if added.contains(Modifier::RAPID_BLINK) {
|
||
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Style;
|
||
|
||
#[test]
|
||
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
|
||
let area = Rect::new(0, 0, 3, 2);
|
||
let previous = Buffer::empty(area);
|
||
let mut next = Buffer::empty(area);
|
||
|
||
next.cell_mut((2, 0))
|
||
.expect("cell should exist")
|
||
.set_symbol("X");
|
||
|
||
let commands = diff_buffers(&previous, &next);
|
||
|
||
let clear_count = commands
|
||
.iter()
|
||
.filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0))
|
||
.count();
|
||
assert_eq!(
|
||
0, clear_count,
|
||
"expected diff_buffers not to emit ClearToEnd; commands: {commands:?}",
|
||
);
|
||
assert!(
|
||
commands
|
||
.iter()
|
||
.any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })),
|
||
"expected diff_buffers to update the final cell; commands: {commands:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_buffers_clear_to_end_starts_after_wide_char() {
|
||
let area = Rect::new(0, 0, 10, 1);
|
||
let mut previous = Buffer::empty(area);
|
||
let mut next = Buffer::empty(area);
|
||
|
||
previous.set_string(0, 0, "中文", Style::default());
|
||
next.set_string(0, 0, "中", Style::default());
|
||
|
||
let commands = diff_buffers(&previous, &next);
|
||
assert!(
|
||
commands
|
||
.iter()
|
||
.any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })),
|
||
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
|
||
);
|
||
}
|
||
}
|