2025-08-11 18:32:59 -07:00
|
|
|
use crossterm::terminal;
|
2025-08-11 12:31:34 -07:00
|
|
|
use ratatui::style::Color;
|
|
|
|
|
use ratatui::style::Modifier;
|
|
|
|
|
use ratatui::style::Style;
|
|
|
|
|
use ratatui::text::Line as RtLine;
|
|
|
|
|
use ratatui::text::Span as RtSpan;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
2025-08-11 18:32:59 -07:00
|
|
|
use crate::common::DEFAULT_WRAP_COLS;
|
2025-08-11 12:31:34 -07:00
|
|
|
use codex_core::protocol::FileChange;
|
|
|
|
|
|
2025-08-11 18:32:59 -07:00
|
|
|
use crate::history_cell::PatchEventType;
|
|
|
|
|
|
|
|
|
|
const SPACES_AFTER_LINE_NUMBER: usize = 6;
|
|
|
|
|
|
|
|
|
|
// Internal representation for diff line rendering
|
|
|
|
|
enum DiffLineType {
|
|
|
|
|
Insert,
|
|
|
|
|
Delete,
|
|
|
|
|
Context,
|
2025-08-11 12:31:34 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn create_diff_summary(
|
|
|
|
|
title: &str,
|
2025-08-11 18:32:59 -07:00
|
|
|
changes: &HashMap<PathBuf, FileChange>,
|
|
|
|
|
event_type: PatchEventType,
|
2025-08-11 12:31:34 -07:00
|
|
|
) -> Vec<RtLine<'static>> {
|
2025-08-11 18:32:59 -07:00
|
|
|
struct FileSummary {
|
|
|
|
|
display_path: String,
|
|
|
|
|
added: usize,
|
|
|
|
|
removed: usize,
|
|
|
|
|
}
|
2025-08-11 12:31:34 -07:00
|
|
|
|
|
|
|
|
let count_from_unified = |diff: &str| -> (usize, usize) {
|
|
|
|
|
if let Ok(patch) = diffy::Patch::from_str(diff) {
|
2025-08-11 18:32:59 -07:00
|
|
|
patch
|
|
|
|
|
.hunks()
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|h| h.lines())
|
|
|
|
|
.fold((0, 0), |(a, d), l| match l {
|
|
|
|
|
diffy::Line::Insert(_) => (a + 1, d),
|
|
|
|
|
diffy::Line::Delete(_) => (a, d + 1),
|
|
|
|
|
_ => (a, d),
|
|
|
|
|
})
|
2025-08-11 12:31:34 -07:00
|
|
|
} else {
|
2025-08-11 18:32:59 -07:00
|
|
|
// Fallback: manual scan to preserve counts even for unparsable diffs
|
2025-08-11 12:31:34 -07:00
|
|
|
let mut adds = 0usize;
|
|
|
|
|
let mut dels = 0usize;
|
|
|
|
|
for l in diff.lines() {
|
|
|
|
|
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
match l.as_bytes().first() {
|
|
|
|
|
Some(b'+') => adds += 1,
|
|
|
|
|
Some(b'-') => dels += 1,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(adds, dels)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-11 18:32:59 -07:00
|
|
|
let mut files: Vec<FileSummary> = Vec::new();
|
|
|
|
|
for (path, change) in changes.iter() {
|
2025-08-11 12:31:34 -07:00
|
|
|
match change {
|
2025-08-11 18:32:59 -07:00
|
|
|
FileChange::Add { content } => files.push(FileSummary {
|
|
|
|
|
display_path: path.display().to_string(),
|
|
|
|
|
added: content.lines().count(),
|
|
|
|
|
removed: 0,
|
|
|
|
|
}),
|
|
|
|
|
FileChange::Delete => files.push(FileSummary {
|
|
|
|
|
display_path: path.display().to_string(),
|
|
|
|
|
added: 0,
|
|
|
|
|
removed: std::fs::read_to_string(path)
|
2025-08-11 12:31:34 -07:00
|
|
|
.ok()
|
|
|
|
|
.map(|s| s.lines().count())
|
2025-08-11 18:32:59 -07:00
|
|
|
.unwrap_or(0),
|
|
|
|
|
}),
|
|
|
|
|
FileChange::Update {
|
2025-08-11 12:31:34 -07:00
|
|
|
unified_diff,
|
|
|
|
|
move_path,
|
|
|
|
|
} => {
|
|
|
|
|
let (added, removed) = count_from_unified(unified_diff);
|
|
|
|
|
let display_path = if let Some(new_path) = move_path {
|
|
|
|
|
format!("{} → {}", path.display(), new_path.display())
|
|
|
|
|
} else {
|
|
|
|
|
path.display().to_string()
|
|
|
|
|
};
|
|
|
|
|
files.push(FileSummary {
|
|
|
|
|
display_path,
|
|
|
|
|
added,
|
|
|
|
|
removed,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let file_count = files.len();
|
|
|
|
|
let total_added: usize = files.iter().map(|f| f.added).sum();
|
|
|
|
|
let total_removed: usize = files.iter().map(|f| f.removed).sum();
|
|
|
|
|
let noun = if file_count == 1 { "file" } else { "files" };
|
|
|
|
|
|
|
|
|
|
let mut out: Vec<RtLine<'static>> = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
|
|
|
|
|
header_spans.push(RtSpan::styled(
|
|
|
|
|
title.to_owned(),
|
|
|
|
|
Style::default()
|
|
|
|
|
.fg(Color::Magenta)
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
));
|
|
|
|
|
header_spans.push(RtSpan::raw(" to "));
|
|
|
|
|
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
|
|
|
|
|
header_spans.push(RtSpan::raw("("));
|
|
|
|
|
header_spans.push(RtSpan::styled(
|
|
|
|
|
format!("+{total_added}"),
|
|
|
|
|
Style::default().fg(Color::Green),
|
|
|
|
|
));
|
|
|
|
|
header_spans.push(RtSpan::raw(" "));
|
|
|
|
|
header_spans.push(RtSpan::styled(
|
|
|
|
|
format!("-{total_removed}"),
|
|
|
|
|
Style::default().fg(Color::Red),
|
|
|
|
|
));
|
|
|
|
|
header_spans.push(RtSpan::raw(")"));
|
|
|
|
|
out.push(RtLine::from(header_spans));
|
|
|
|
|
|
|
|
|
|
// Dimmed per-file lines with prefix
|
|
|
|
|
for (idx, f) in files.iter().enumerate() {
|
|
|
|
|
let mut spans: Vec<RtSpan<'static>> = Vec::new();
|
|
|
|
|
spans.push(RtSpan::raw(f.display_path.clone()));
|
2025-08-19 16:49:08 -07:00
|
|
|
// Show per-file +/- counts only when there are multiple files
|
|
|
|
|
if file_count > 1 {
|
|
|
|
|
spans.push(RtSpan::raw(" ("));
|
|
|
|
|
spans.push(RtSpan::styled(
|
|
|
|
|
format!("+{}", f.added),
|
|
|
|
|
Style::default().fg(Color::Green),
|
|
|
|
|
));
|
|
|
|
|
spans.push(RtSpan::raw(" "));
|
|
|
|
|
spans.push(RtSpan::styled(
|
|
|
|
|
format!("-{}", f.removed),
|
|
|
|
|
Style::default().fg(Color::Red),
|
|
|
|
|
));
|
|
|
|
|
spans.push(RtSpan::raw(")"));
|
|
|
|
|
}
|
2025-08-11 12:31:34 -07:00
|
|
|
|
|
|
|
|
let mut line = RtLine::from(spans);
|
2025-08-13 19:14:03 -04:00
|
|
|
let prefix = if idx == 0 { " └ " } else { " " };
|
2025-08-11 12:31:34 -07:00
|
|
|
line.spans.insert(0, prefix.into());
|
2025-08-11 18:32:59 -07:00
|
|
|
line.spans
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
|
2025-08-11 12:31:34 -07:00
|
|
|
out.push(line);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 18:32:59 -07:00
|
|
|
let show_details = matches!(
|
|
|
|
|
event_type,
|
|
|
|
|
PatchEventType::ApplyBegin {
|
|
|
|
|
auto_approved: true
|
|
|
|
|
} | PatchEventType::ApprovalRequest
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if show_details {
|
|
|
|
|
out.extend(render_patch_details(changes));
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 12:31:34 -07:00
|
|
|
out
|
|
|
|
|
}
|
2025-08-11 18:32:59 -07:00
|
|
|
|
|
|
|
|
fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
|
|
|
|
|
let mut out: Vec<RtLine<'static>> = Vec::new();
|
|
|
|
|
let term_cols: usize = terminal::size()
|
|
|
|
|
.map(|(w, _)| w as usize)
|
|
|
|
|
.unwrap_or(DEFAULT_WRAP_COLS.into());
|
|
|
|
|
|
|
|
|
|
for (index, (path, change)) in changes.iter().enumerate() {
|
|
|
|
|
let is_first_file = index == 0;
|
|
|
|
|
// Add separator only between files (not at the very start)
|
|
|
|
|
if !is_first_file {
|
|
|
|
|
out.push(RtLine::from(vec![
|
|
|
|
|
RtSpan::raw(" "),
|
|
|
|
|
RtSpan::styled("...", style_dim()),
|
|
|
|
|
]));
|
|
|
|
|
}
|
|
|
|
|
match change {
|
|
|
|
|
FileChange::Add { content } => {
|
|
|
|
|
for (i, raw) in content.lines().enumerate() {
|
|
|
|
|
let ln = i + 1;
|
|
|
|
|
out.extend(push_wrapped_diff_line(
|
|
|
|
|
ln,
|
|
|
|
|
DiffLineType::Insert,
|
|
|
|
|
raw,
|
|
|
|
|
term_cols,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
FileChange::Delete => {
|
|
|
|
|
let original = std::fs::read_to_string(path).unwrap_or_default();
|
|
|
|
|
for (i, raw) in original.lines().enumerate() {
|
|
|
|
|
let ln = i + 1;
|
|
|
|
|
out.extend(push_wrapped_diff_line(
|
|
|
|
|
ln,
|
|
|
|
|
DiffLineType::Delete,
|
|
|
|
|
raw,
|
|
|
|
|
term_cols,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
FileChange::Update {
|
|
|
|
|
unified_diff,
|
|
|
|
|
move_path: _,
|
|
|
|
|
} => {
|
|
|
|
|
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
|
|
|
|
|
for h in patch.hunks() {
|
|
|
|
|
let mut old_ln = h.old_range().start();
|
|
|
|
|
let mut new_ln = h.new_range().start();
|
|
|
|
|
for l in h.lines() {
|
|
|
|
|
match l {
|
|
|
|
|
diffy::Line::Insert(text) => {
|
|
|
|
|
let s = text.trim_end_matches('\n');
|
|
|
|
|
out.extend(push_wrapped_diff_line(
|
|
|
|
|
new_ln,
|
|
|
|
|
DiffLineType::Insert,
|
|
|
|
|
s,
|
|
|
|
|
term_cols,
|
|
|
|
|
));
|
|
|
|
|
new_ln += 1;
|
|
|
|
|
}
|
|
|
|
|
diffy::Line::Delete(text) => {
|
|
|
|
|
let s = text.trim_end_matches('\n');
|
|
|
|
|
out.extend(push_wrapped_diff_line(
|
|
|
|
|
old_ln,
|
|
|
|
|
DiffLineType::Delete,
|
|
|
|
|
s,
|
|
|
|
|
term_cols,
|
|
|
|
|
));
|
|
|
|
|
old_ln += 1;
|
|
|
|
|
}
|
|
|
|
|
diffy::Line::Context(text) => {
|
|
|
|
|
let s = text.trim_end_matches('\n');
|
|
|
|
|
out.extend(push_wrapped_diff_line(
|
|
|
|
|
new_ln,
|
|
|
|
|
DiffLineType::Context,
|
|
|
|
|
s,
|
|
|
|
|
term_cols,
|
|
|
|
|
));
|
|
|
|
|
old_ln += 1;
|
|
|
|
|
new_ln += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out.push(RtLine::from(RtSpan::raw("")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_wrapped_diff_line(
|
|
|
|
|
line_number: usize,
|
|
|
|
|
kind: DiffLineType,
|
|
|
|
|
text: &str,
|
|
|
|
|
term_cols: usize,
|
|
|
|
|
) -> Vec<RtLine<'static>> {
|
|
|
|
|
let indent = " ";
|
|
|
|
|
let ln_str = line_number.to_string();
|
|
|
|
|
let mut remaining_text: &str = text;
|
|
|
|
|
|
|
|
|
|
// Reserve a fixed number of spaces after the line number so that content starts
|
2025-08-15 12:32:45 -04:00
|
|
|
// at a consistent column. Content includes a 1-character diff sign prefix
|
|
|
|
|
// ("+"/"-" for inserts/deletes, or a space for context lines) so alignment
|
|
|
|
|
// stays consistent across all diff lines.
|
2025-08-11 18:32:59 -07:00
|
|
|
let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
|
|
|
|
|
let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
|
|
|
|
|
let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
|
|
|
|
|
|
|
|
|
|
let mut first = true;
|
2025-08-13 14:21:24 -07:00
|
|
|
let (sign_opt, line_style) = match kind {
|
2025-08-11 18:32:59 -07:00
|
|
|
DiffLineType::Insert => (Some('+'), Some(style_add())),
|
|
|
|
|
DiffLineType::Delete => (Some('-'), Some(style_del())),
|
|
|
|
|
DiffLineType::Context => (None, None),
|
|
|
|
|
};
|
|
|
|
|
let mut lines: Vec<RtLine<'static>> = Vec::new();
|
|
|
|
|
while !remaining_text.is_empty() {
|
|
|
|
|
let prefix_cols = if first {
|
|
|
|
|
first_prefix_cols
|
|
|
|
|
} else {
|
|
|
|
|
cont_prefix_cols
|
|
|
|
|
};
|
|
|
|
|
// Fit the content for the current terminal row:
|
|
|
|
|
// compute how many columns are available after the prefix, then split
|
|
|
|
|
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
|
|
|
|
let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1);
|
|
|
|
|
let split_at_byte_index = remaining_text
|
|
|
|
|
.char_indices()
|
|
|
|
|
.nth(available_content_cols)
|
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
.unwrap_or_else(|| remaining_text.len());
|
|
|
|
|
let (chunk, rest) = remaining_text.split_at(split_at_byte_index);
|
|
|
|
|
remaining_text = rest;
|
|
|
|
|
|
|
|
|
|
if first {
|
|
|
|
|
let mut spans: Vec<RtSpan<'static>> = Vec::new();
|
|
|
|
|
spans.push(RtSpan::raw(indent));
|
|
|
|
|
spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
|
|
|
|
|
spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
|
2025-08-15 12:32:45 -04:00
|
|
|
// Always include a sign character at the start of the displayed chunk
|
|
|
|
|
// ('+' for insert, '-' for delete, ' ' for context) so gutters align.
|
|
|
|
|
let sign_char = sign_opt.unwrap_or(' ');
|
|
|
|
|
let display_chunk = format!("{sign_char}{chunk}");
|
2025-08-13 14:21:24 -07:00
|
|
|
let content_span = match line_style {
|
2025-08-11 18:32:59 -07:00
|
|
|
Some(style) => RtSpan::styled(display_chunk, style),
|
|
|
|
|
None => RtSpan::raw(display_chunk),
|
|
|
|
|
};
|
|
|
|
|
spans.push(content_span);
|
2025-08-13 14:21:24 -07:00
|
|
|
let mut line = RtLine::from(spans);
|
|
|
|
|
if let Some(style) = line_style {
|
|
|
|
|
line.style = line.style.patch(style);
|
|
|
|
|
}
|
|
|
|
|
lines.push(line);
|
2025-08-11 18:32:59 -07:00
|
|
|
first = false;
|
|
|
|
|
} else {
|
2025-08-15 12:32:45 -04:00
|
|
|
// Continuation lines keep a space for the sign column so content aligns
|
2025-08-11 18:32:59 -07:00
|
|
|
let hang_prefix = format!(
|
2025-08-15 12:32:45 -04:00
|
|
|
"{indent}{}{} ",
|
2025-08-11 18:32:59 -07:00
|
|
|
" ".repeat(ln_str.len()),
|
|
|
|
|
" ".repeat(gap_after_ln)
|
|
|
|
|
);
|
2025-08-13 14:21:24 -07:00
|
|
|
let content_span = match line_style {
|
2025-08-11 18:32:59 -07:00
|
|
|
Some(style) => RtSpan::styled(chunk.to_string(), style),
|
|
|
|
|
None => RtSpan::raw(chunk.to_string()),
|
|
|
|
|
};
|
2025-08-13 14:21:24 -07:00
|
|
|
let mut line = RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]);
|
|
|
|
|
if let Some(style) = line_style {
|
|
|
|
|
line.style = line.style.patch(style);
|
|
|
|
|
}
|
|
|
|
|
lines.push(line);
|
2025-08-11 18:32:59 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn style_dim() -> Style {
|
|
|
|
|
Style::default().add_modifier(Modifier::DIM)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn style_add() -> Style {
|
2025-08-13 14:21:24 -07:00
|
|
|
Style::default().fg(Color::Green)
|
2025-08-11 18:32:59 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn style_del() -> Style {
|
2025-08-13 14:21:24 -07:00
|
|
|
Style::default().fg(Color::Red)
|
2025-08-11 18:32:59 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use insta::assert_snapshot;
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
use ratatui::backend::TestBackend;
|
2025-08-14 14:10:05 -04:00
|
|
|
use ratatui::text::Text;
|
|
|
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
|
use ratatui::widgets::Wrap;
|
2025-08-11 18:32:59 -07:00
|
|
|
|
|
|
|
|
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
|
|
|
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
|
|
|
|
terminal
|
2025-08-14 14:10:05 -04:00
|
|
|
.draw(|f| {
|
|
|
|
|
Paragraph::new(Text::from(lines))
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.render_ref(f.area(), f.buffer_mut())
|
|
|
|
|
})
|
2025-08-11 18:32:59 -07:00
|
|
|
.expect("draw");
|
|
|
|
|
assert_snapshot!(name, terminal.backend());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ui_snapshot_add_details() {
|
|
|
|
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
|
|
|
changes.insert(
|
|
|
|
|
PathBuf::from("README.md"),
|
|
|
|
|
FileChange::Add {
|
|
|
|
|
content: "first line\nsecond line\n".to_string(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let lines =
|
|
|
|
|
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
|
|
|
|
|
|
|
|
snapshot_lines("add_details", lines, 80, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ui_snapshot_update_details_with_rename() {
|
|
|
|
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
|
|
|
|
|
|
|
|
let original = "line one\nline two\nline three\n";
|
|
|
|
|
let modified = "line one\nline two changed\nline three\n";
|
|
|
|
|
let patch = diffy::create_patch(original, modified).to_string();
|
|
|
|
|
|
|
|
|
|
changes.insert(
|
|
|
|
|
PathBuf::from("src/lib.rs"),
|
|
|
|
|
FileChange::Update {
|
|
|
|
|
unified_diff: patch,
|
|
|
|
|
move_path: Some(PathBuf::from("src/lib_new.rs")),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let lines =
|
|
|
|
|
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
|
|
|
|
|
|
|
|
|
|
snapshot_lines("update_details_with_rename", lines, 80, 12);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ui_snapshot_wrap_behavior_insert() {
|
|
|
|
|
// Narrow width to force wrapping within our diff line rendering
|
|
|
|
|
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
|
|
|
|
|
|
|
|
|
|
// Call the wrapping function directly so we can precisely control the width
|
|
|
|
|
let lines =
|
|
|
|
|
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
|
|
|
|
|
|
|
|
|
|
// Render into a small terminal to capture the visual layout
|
|
|
|
|
snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
|
|
|
|
|
}
|
|
|
|
|
}
|