feat: mark diff files as reviewed

This commit is contained in:
Valdemar Lorenzen
2026-10-13 13:07:02 +02:00
parent c912abaf17
commit 38fcf86195
6 changed files with 522 additions and 38 deletions
+190
View File
@@ -213,6 +213,7 @@ pub struct DiffState {
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub comparison_id: Option<u64>,
pub reviewed_files: BTreeSet<usize>,
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
pub files: Option<FilePicker>,
pub files_visible: bool,
@@ -259,6 +260,7 @@ impl DiffState {
error: None,
comparison: None,
comparison_id: None,
reviewed_files: BTreeSet::new(),
files: None,
snapshot_roots: [None, None],
files_visible: true,
@@ -371,6 +373,7 @@ impl DiffState {
}
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
self.live_files.clear();
self.reviewed_files.clear();
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
@@ -649,6 +652,7 @@ impl DiffState {
self.phase = Phase::After;
self.comparison = None;
self.comparison_id = None;
self.reviewed_files.clear();
self.queue(operation);
}
}
@@ -872,6 +876,45 @@ impl DiffState {
})
}
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
let comparison = self
.comparison
.as_ref()
.filter(|_| self.phase == Phase::View)
.ok_or("No comparison is open")?;
let index = if self.focus == Focus::Files {
let entry = self
.files
.as_ref()
.filter(|_| self.files_visible)
.and_then(|files| files.entries().nth(files.selected))
.ok_or("No changed file is selected")?;
if entry.is_dir {
return Err("Select a changed file, not a directory");
}
entry
.catalog_index
.filter(|&index| {
comparison
.files
.get(index)
.is_some_and(|file| file.path == entry.path)
})
.ok_or("No changed file is selected")?
} else {
self.file
};
if comparison.files.get(index).is_none() {
return Err("No changed file is selected");
}
if self.reviewed_files.remove(&index) {
Ok(false)
} else {
self.reviewed_files.insert(index);
Ok(true)
}
}
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
@@ -1138,6 +1181,17 @@ impl DiffState {
let file = &self.comparison.as_ref().unwrap().files[self.file];
let was_live = self.live_files.contains(&self.file);
let changed_lines = changed_line_range(&file.after, lines);
if self.reviewed_files.contains(&self.file) {
let unchanged = if was_live {
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
} else {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.after_eof == eof && file.after == live_content(lines, eof)
};
if !unchanged {
self.reviewed_files.remove(&self.file);
}
}
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
self.live_revision = Some(revision);
return;
@@ -3094,6 +3148,142 @@ mod tests {
state
}
#[test]
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
let mut state = DiffState::new();
let mut changes = comparison();
changes.files[0].path = "nested/z.txt".into();
changes.files[1].path = "nested/a.txt".into();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
for view in [FileView::List, FileView::Tree] {
let files = state.files.as_mut().unwrap();
files.set_view(view);
files.clear_filter();
files.start_search();
for ch in "nested/a".chars() {
files.handle_filter_input(&Input::Char(ch));
}
files.reveal_path(Path::new("nested/a.txt"));
assert_eq!(state.file, 0);
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
assert_eq!(state.toggle_reviewed(), Ok(false));
assert!(state.reviewed_files.is_empty());
}
let files = state.files.as_mut().unwrap();
files.clear_filter();
files.finish_search();
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
files.selected = selected;
assert_eq!(
state.toggle_reviewed(),
Err("Select a changed file, not a directory")
);
assert!(state.reviewed_files.is_empty());
let files = state.files.as_mut().unwrap();
files.start_search();
for ch in "missing".chars() {
files.handle_filter_input(&Input::Char(ch));
}
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[(0, PathBuf::from("ordinary.txt"))],
));
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
assert!(state.select_file_index(2));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
}
#[test]
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
let mut state = DiffState::new();
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
let mut changes = comparison();
changes.files[0].loaded = false;
changes.files[0].before.clear();
changes.files[0].after.clear();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
let file = comparison().files.remove(0);
state.apply_result(
2,
Ok(Outcome::File {
comparison_id: 1,
file_index: 0,
file,
}),
);
assert!(state.selected_file_loaded());
state.set_view_mode(ViewMode::Unified);
state.set_view_mode(ViewMode::Split);
state.split_axis = crate::panes::SplitAxis::Horizontal;
assert!(state.select_file_index(1));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.before = Some(revision(
RevisionTarget::Commit("base".into()),
"base",
"",
Category::Commits,
));
state.after = Some(revision(
RevisionTarget::Commit("head".into()),
"head",
"",
Category::Commits,
));
state.toggle_comparison_mode().unwrap();
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
}
#[test]
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
for (after, eof, lines) in [
(vec!["changed".into()], false, vec!["changed".into()]),
(
vec!["changed".into()],
true,
vec!["changed".into(), String::new()],
),
(Vec::new(), false, vec![String::new()]),
] {
let mut state = live_state(vec!["base".into()], after, eof);
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&lines, 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&lines, 2);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&["edited".into()], 3);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into(), String::new()], 4);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 5);
assert!(state.reviewed_files.is_empty());
}
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.comparison.as_mut().unwrap().files[0].loaded = false;
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
}
#[test]
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
let mut state = live_state(
+186 -2
View File
@@ -766,6 +766,12 @@ core_actions![
&[Mode::Normal],
true,
),
(
"core.diff.review.toggle",
Action::DiffReviewToggle,
&[Mode::Normal],
true,
),
(
"core.word_wrap.toggle",
Action::WordWrapToggle,
@@ -3246,6 +3252,7 @@ impl Editor {
| Action::DiffModeToggle
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::DiffReviewToggle
| Action::WordWrapToggle
| Action::GitBlameToggle
| Action::Quit
@@ -6185,6 +6192,7 @@ impl Editor {
| Action::DiffModeToggle
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::DiffReviewToggle
| Action::WordWrapToggle
| Action::GitBlameToggle
| Action::LspDefinition
@@ -7002,13 +7010,25 @@ impl Editor {
}
if matches!(
entry.action,
Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle
Action::DiffModeToggle
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::DiffReviewToggle
) {
let name = entry.ex.expect("diff display action has an Ex command");
let name = entry.ex.expect("diff action has an Ex command");
if !args.is_empty() {
self.set_message(format!("The {name} command takes no arguments"));
return None;
}
if entry.action == Action::DiffReviewToggle
&& self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
{
self.set_message("Select a file in the comparison");
return None;
}
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
return self.with_pane(owner, |editor| editor.execute_action(entry, args));
}
@@ -7036,6 +7056,14 @@ impl Editor {
self.set_message("No comparison is open");
return None;
};
if entry.action == Action::DiffReviewToggle {
match diff.toggle_reviewed() {
Ok(true) => self.set_message("File marked reviewed"),
Ok(false) => self.set_message("File marked unreviewed"),
Err(error) => self.set_message(error),
}
return None;
}
if entry.action == Action::DiffViewToggle {
diff.set_view_mode(match diff.view_mode {
ViewMode::Split => ViewMode::Unified,
@@ -10285,6 +10313,7 @@ fn atomic_save(
mod tests {
use super::*;
use crate::plugin::CompletionEdit;
use std::collections::BTreeSet;
struct Directory(PathBuf);
impl Directory {
@@ -13479,6 +13508,161 @@ mod tests {
assert_eq!(editor.lines(), ["beta"]);
}
#[test]
fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() {
let directory = Directory::new();
let mut editor = editor();
editor.execute("diff.review.toggle");
assert_eq!(editor.message(), "No comparison is open");
historical_diff(
&mut editor,
&directory.0,
vec!["before".into(), "second".into()],
vec!["after".into(), "second".into()],
);
editor
.bind("normal", "ctrl+y", Some("core.diff.review.toggle".into()))
.unwrap();
let text = editor.lines().to_vec();
let carets = editor.carets();
let revision = editor.text_revision();
let undo = editor.active.undo.len();
let redo = editor.active.redo.len();
let dirty = editor.dirty();
assert!(editor.mutation_blocked());
editor.handle(Input::Ctrl('y'));
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
editor.execute("diff.review.toggle unexpected");
assert!(editor.message().contains("takes no arguments"));
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "diff.review.toggle");
editor.handle(Input::Enter);
assert!(editor.diff().unwrap().reviewed_files.is_empty());
assert_eq!(editor.lines(), text);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.active.undo.len(), undo);
assert_eq!(editor.active.redo.len(), redo);
assert_eq!(editor.dirty(), dirty);
assert!(!directory.path("file.txt").exists());
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
assert!(editor.mutation_blocked());
editor.execute(":diff.review.toggle");
editor.execute("diff.view.toggle");
editor.execute("diff.view.toggle");
editor.execute("diff.orientation.toggle");
let first_tab = editor.tab_id();
editor.execute("tab.new");
historical_diff(
&mut editor,
&directory.0,
vec!["base".into()],
vec!["different".into()],
);
assert!(editor.diff().unwrap().reviewed_files.is_empty());
editor.execute("tab.previous");
assert_eq!(editor.tab_id(), first_tab);
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
editor.execute("diff.close");
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["after".into()],
);
assert!(editor.diff().unwrap().reviewed_files.is_empty());
}
#[test]
fn diff_review_files_action_targets_highlight_without_stealing_filter_text() {
let directory = Directory::new();
fs::write(directory.path("z.txt"), "z").unwrap();
fs::write(directory.path("a.txt"), "a").unwrap();
let mut editor = editor();
working_diff(&mut editor, &directory.0, &["z.txt", "a.txt"]);
editor
.bind("normal", "ctrl+y", Some("core.diff.review.toggle".into()))
.unwrap();
editor
.bind("normal", "Q", Some("core.diff.review.toggle".into()))
.unwrap();
let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap();
files.set_view(FileView::List);
files.reveal_path(Path::new("a.txt"));
editor.handle(Input::Ctrl('y'));
assert_eq!(editor.diff().unwrap().file, 0);
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
editor.handle(Input::Char('Q'));
assert_eq!(editor.file_picker().unwrap().query, "Q");
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
editor.handle(Input::Ctrl('u'));
let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap();
files.set_view(FileView::Tree);
files.start_search();
editor.handle(Input::Char('Q'));
assert_eq!(editor.file_picker().unwrap().query, "Q");
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
editor.handle(Input::Ctrl('y'));
assert_eq!(editor.message(), "No changed file is selected");
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
editor.file_picker = Some(
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::List,
)
.unwrap(),
);
editor.execute("diff.review.toggle");
assert_eq!(editor.message(), "Select a file in the comparison");
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
}
#[test]
fn working_review_marks_survive_save_and_clear_only_the_edited_file() {
let directory = Directory::new();
let first = directory.path("first.txt");
fs::write(&first, "base\n").unwrap();
fs::write(directory.path("second.txt"), "second").unwrap();
let mut editor = Editor::open(Some(first.clone()), vec![]).unwrap();
working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]);
editor.execute("diff.review.toggle");
editor
.diff
.as_mut()
.unwrap()
.files
.as_mut()
.unwrap()
.reveal_path(Path::new("second.txt"));
editor.execute("diff.review.toggle");
assert_eq!(
editor.diff().unwrap().reviewed_files,
BTreeSet::from([0, 1])
);
editor.handle(Input::BackTab);
assert!(editor.diff_editing());
editor.execute("w");
assert_eq!(
editor.diff().unwrap().reviewed_files,
BTreeSet::from([0, 1])
);
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
editor.execute("diff.review.toggle");
editor.execute("w");
assert_eq!(
editor.diff().unwrap().reviewed_files,
BTreeSet::from([0, 1])
);
assert_eq!(fs::read_to_string(&first).unwrap(), "Xbase\n");
editor.handle(Input::Char('u'));
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
}
#[test]
fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() {
let directory = Directory::new();
+136 -34
View File
@@ -1,6 +1,6 @@
use std::{
borrow::Cow,
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
fmt::Write as _,
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
@@ -514,10 +514,11 @@ impl BufferViewports {
panel,
editor.file_navigation_hint(),
&mut self.panels,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
editor.diff().and_then(|diff| {
diff.comparison
.as_ref()
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
}),
);
if let Some(geometry) = &mut self.panels.diff {
geometry.regions[0] = (panel, Focus::Files);
@@ -2060,10 +2061,11 @@ fn draw_overlays(
panel,
editor.file_navigation_hint(),
panels,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
editor.diff().and_then(|diff| {
diff.comparison
.as_ref()
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
}),
);
}
if let Some(palette) = editor.palette() {
@@ -2774,7 +2776,7 @@ fn draw_diff(
navigator,
file_navigation_hint,
panels,
comparison.map(|comparison| comparison.files.as_slice()),
comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)),
);
}
let mut text_widths = [0; 2];
@@ -4128,7 +4130,7 @@ fn draw_file_list(
panel: Rect,
navigation_hint: &str,
panels: &mut WheelPanels,
changed_files: Option<&[ChangedFile]>,
changed_files: Option<(&[ChangedFile], &BTreeSet<usize>)>,
) {
if panel.width == 0 || panel.height == 0 {
return;
@@ -4262,7 +4264,7 @@ fn draw_file_list(
}
let changed = entry
.catalog_index
.and_then(|index| changed_files.and_then(|files| files.get(index)));
.and_then(|index| changed_files.and_then(|(files, _)| files.get(index)));
let prefix = format!(
"{} {:indent$}",
if selected { ">" } else { " " },
@@ -4273,9 +4275,16 @@ fn draw_file_list(
0
},
);
let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 });
let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 });
spans.push(Span::raw(prefix));
if let Some(file) = changed {
let reviewed = entry.catalog_index.is_some_and(|index| {
changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index))
});
spans.push(Span::styled(
if reviewed { "✓ " } else { " " },
Style::default().fg(Color::Green),
));
let color = match file.status {
FileStatus::Added => Color::Green,
FileStatus::Removed => Color::Red,
@@ -5664,6 +5673,7 @@ mod tests {
picker.focused = focused;
for index in 0..files.len() {
picker.reveal_path(&files[index].path);
let reviewed = BTreeSet::from([index]);
terminal
.draw(|frame| {
draw_file_list(
@@ -5672,15 +5682,15 @@ mod tests {
frame.area(),
"",
&mut WheelPanels::default(),
Some(&files),
Some((&files, &reviewed)),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for (file, color) in
files
.iter()
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
for (catalog_index, (file, color)) in files
.iter()
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
.enumerate()
{
let path = if view == FileView::Tree {
std::path::Path::new(file.path.file_name().unwrap())
@@ -5703,6 +5713,30 @@ mod tests {
})
.unwrap_or_else(|| panic!("missing {expected}"));
assert_eq!(buffer[(x, y)].fg, color);
assert_eq!(
buffer[(x - 2, y)].symbol(),
if catalog_index == index { "✓" } else { " " }
);
assert_eq!(buffer[(x - 1, y)].symbol(), " ");
if catalog_index == index {
assert_eq!(buffer[(x - 2, y)].fg, Color::Green);
assert_eq!(
buffer[(x - 2, y)].bg,
if focused {
Color::Blue
} else {
Color::DarkGray
}
);
}
}
for row in screen_text(&terminal)
.lines()
.filter(|row| row.contains("chain/a/"))
{
if view == FileView::Tree {
assert!(!row.contains('✓'));
}
}
assert_eq!(picker.accept(), Some(files[index].path.clone()));
assert!(picker.query.is_empty());
@@ -5717,22 +5751,35 @@ mod tests {
.skip(1)
.map(|(index, file)| (index, file.path.clone())),
);
picker.query = "m.txt".into();
picker.refilter();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
Some(&files),
);
})
.unwrap();
assert!(screen_text(&terminal).contains("M chain/a/m.txt"));
assert!(!screen_text(&terminal).contains("R r.txt"));
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
picker.query = "m.txt".into();
picker.refilter();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
Some((&files, &BTreeSet::from([1]))),
);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("✓ M "));
assert_eq!(text.matches('✓').count(), 1);
assert!(!text.contains("R r.txt"));
assert_eq!(
picker
.entries()
.find(|entry| !entry.is_dir)
.unwrap()
.catalog_index,
Some(1)
);
}
terminal
.draw(|frame| {
draw_file_list(
@@ -5746,6 +5793,61 @@ mod tests {
})
.unwrap();
assert!(!screen_text(&terminal).contains("M chain/a/m.txt"));
assert!(!screen_text(&terminal).contains('✓'));
}
#[test]
fn reviewed_file_prefix_tracks_state_and_clips_in_narrow_panes() {
let mut file = deletion_file();
file.path = "界.txt".into();
file.status = FileStatus::Modified;
let files = [file];
let mut picker = FilePicker::from_paths(PathBuf::new(), [(0, files[0].path.clone())]);
picker.set_view(FileView::List);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap();
let mut reviewed = BTreeSet::new();
for marked in [true, false, true] {
if marked {
reviewed.insert(0);
} else {
reviewed.remove(&0);
}
for width in 1..=8 {
terminal
.draw(|frame| {
frame.render_widget(
Paragraph::new("XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX"),
frame.area(),
);
draw_file_list(
frame,
&picker,
Rect::new(0, 0, width, 3),
"",
&mut WheelPanels::default(),
Some((&files, &reviewed)),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let prefix = if marked { "> ✓ M " } else { "> M " };
for (x, symbol) in prefix.chars().take(usize::from(width)).enumerate() {
assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string());
}
for x in width..12 {
assert_eq!(buffer[(x, 1)].symbol(), "X");
}
if width >= 3 && marked {
assert_eq!(buffer[(2, 1)].fg, Color::Green);
}
if width >= 5 {
assert_eq!(buffer[(4, 1)].fg, Color::Yellow);
}
if width >= 8 {
assert_eq!(buffer[(6, 1)].symbol(), "界");
}
}
}
}
#[test]