改行テキストの行計算・Padding設定

This commit is contained in:
Keisuke Hirata 2026-04-21 23:26:34 +09:00
parent 84fedd8048
commit 676137c246
2 changed files with 150 additions and 107 deletions

View File

@ -88,7 +88,6 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend
format!("[tool] Read — {count} file{} read", plural(count))
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(header, tool_style),
]));
@ -106,13 +105,13 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend
let start_idx = paths.len().saturating_sub(limit);
for p in &paths[start_idx..] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(p.clone(), path_style),
]));
}
if in_progress && paths.len() > limit {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
format!("… ({} earlier)", paths.len() - limit),
Style::default().fg(Color::DarkGray),
@ -162,7 +161,6 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
if matches!(mode, Mode::Overview) {
return vec![Line::from(vec![
Span::raw(" "),
Span::styled("[tool] Write — ".to_owned(), tool_style),
Span::styled(format!("{label} "), label_style),
Span::styled(path, Style::default().fg(Color::White)),
@ -171,7 +169,6 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
let mut lines = vec![
Line::from(vec![
Span::raw(" "),
Span::styled("[tool] Write — ".to_owned(), tool_style),
Span::styled(format!("{label} "), label_style),
Span::styled(path.clone(), Style::default().fg(Color::White)),
@ -193,13 +190,13 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
let body_style = Style::default().fg(Color::Gray);
for l in &body_lines[..shown] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled((*l).to_owned(), body_style),
]));
}
if body_lines.len() > shown {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
format!("… +{} more lines", body_lines.len() - shown),
Style::default().fg(Color::DarkGray),
@ -232,7 +229,6 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'s
let tool_style = Style::default().fg(Color::Cyan);
let header = Line::from(vec![
Span::raw(" "),
Span::styled(format!("[tool] Edit — {}", path), tool_style),
Span::styled(
format!(" ({})", state_suffix(&tc.state)),
@ -258,7 +254,7 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'s
}
} else {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
"(no cached content — run Read first for a diff view)".to_owned(),
Style::default().fg(Color::DarkGray),
@ -274,7 +270,7 @@ fn build_edit_diff(content: &str, old: &str, new: &str) -> Vec<Line<'static>> {
// Locate the first (and typically only) match.
let Some(idx) = content.find(old) else {
return vec![Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
"(old_string not found in cached content)".to_owned(),
Style::default().fg(Color::DarkGray),
@ -301,25 +297,25 @@ fn build_edit_diff(content: &str, old: &str, new: &str) -> Vec<Line<'static>> {
for l in &all_lines[ctx_start..line_of_idx] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(format!(" {l}"), ctx_style),
]));
}
for l in old.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(format!("-{l}"), minus_style),
]));
}
for l in new.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(format!("+{l}"), plus_style),
]));
}
for l in &all_lines[line_of_idx + replaced_line_count..ctx_end] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(format!(" {l}"), ctx_style),
]));
}
@ -347,14 +343,12 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec<Line<'stati
.unwrap_or(state_suffix(&tc.state))
.to_owned();
return vec![Line::from(vec![
Span::raw(" "),
Span::styled(format!("[tool] {label}"), tool_style),
Span::styled(first, Style::default().fg(Color::White)),
])];
}
let mut lines = vec![Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[tool] {label}{}", state_suffix(&tc.state)),
tool_style,
@ -371,13 +365,13 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec<Line<'stati
let body_style = Style::default().fg(Color::Gray);
for l in &body_lines[..shown] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled((*l).to_owned(), body_style),
]));
}
if body_lines.len() > shown {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
format!("… +{} more lines", body_lines.len() - shown),
Style::default().fg(Color::DarkGray),
@ -408,13 +402,11 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
format!("[tool] {}{suffix}", tc.name)
};
return vec![Line::from(vec![
Span::raw(" "),
Span::styled(label, tool_style),
])];
}
let mut lines = vec![Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[tool] {}{}", tc.name, state_suffix(&tc.state)),
tool_style,
@ -484,7 +476,7 @@ fn maybe_error_line(lines: &mut Vec<Line<'static>>, state: &ToolCallState) {
match state {
ToolCallState::Error { summary, .. } => {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
format!("error: {}", first_line(summary)),
Style::default().fg(Color::Red),
@ -493,7 +485,7 @@ fn maybe_error_line(lines: &mut Vec<Line<'static>>, state: &ToolCallState) {
}
ToolCallState::Incomplete => {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
"(no result before turn ended)".to_owned(),
Style::default().fg(Color::Red),
@ -514,13 +506,13 @@ fn emit_capped_lines(
let shown = all.len().min(cap);
for l in &all[..shown] {
out.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled((*l).to_owned(), style),
]));
}
if all.len() > shown {
out.push(Line::from(vec![
Span::raw(" "),
Span::raw(" "),
Span::styled(
format!("… +{} more lines", all.len() - shown),
Style::default().fg(Color::DarkGray),

View File

@ -18,6 +18,7 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
use unicode_width::UnicodeWidthChar;
use protocol::{Greeting, NotificationLevel};
@ -92,36 +93,58 @@ pub struct HistoryLayout {
}
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut turn_starts: Vec<usize> = Vec::new();
// Step 1: collect logical lines from each block (unwrapped).
let mut logical: Vec<Line<'static>> = Vec::new();
let mut logical_turn_starts: Vec<usize> = Vec::new();
let mut first = true;
let mut i = 0;
while i < app.blocks.len() {
if !first {
lines.push(Line::from(""));
logical.push(Line::from(""));
}
first = false;
let block = &app.blocks[i];
if matches!(block, Block::TurnHeader { .. }) {
turn_starts.push(lines.len());
logical_turn_starts.push(logical.len());
}
// Tool calls route through the per-tool renderer, which may
// consume multiple adjacent blocks (Read aggregation).
if matches!(block, Block::ToolCall(_)) {
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode);
lines.extend(out.lines);
logical.extend(out.lines);
i += out.consumed.max(1);
continue;
}
render_block_into(&mut lines, block, width, app.mode);
render_block_into(&mut logical, block, width, app.mode);
i += 1;
}
// Step 2: pre-wrap every logical line to char-based terminal rows so
// scroll math is exact. Track the logical → wrapped mapping so
// turn-start indices get translated into wrapped-row coordinates.
let mut lines: Vec<Line<'static>> = Vec::with_capacity(logical.len());
let mut logical_to_wrapped: Vec<usize> = Vec::with_capacity(logical.len() + 1);
for line in logical {
logical_to_wrapped.push(lines.len());
wrap_line_into(line, width, &mut lines);
}
logical_to_wrapped.push(lines.len());
let turn_starts = logical_turn_starts
.into_iter()
.map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len()))
.collect();
HistoryLayout { lines, turn_starts }
}
/// Maximum body lines a normal-mode block may emit before truncation.
const NORMAL_MAX_LINES: usize = 6;
/// Horizontal gutter around the log area. Applied via a
/// [`Block`](ratatui::widgets::Block)'s padding so *every* row — including
/// continuation rows from wrapping — sits inside the same margin, no
/// leading-space hacks in the content itself.
const HISTORY_PADDING: u16 = 1;
fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
if area.height == 0 || area.width == 0 {
app.scroll.area_height = area.height;
@ -130,16 +153,18 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.turn_starts.clear();
return;
}
let width = area.width;
let HistoryLayout { lines, turn_starts } = compute_history(app, width);
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
// Cache for key handlers. Computing `tail_top_offset` wrap-aware
// — i.e. in post-wrap terminal rows — is what keeps long CJK
// responses visible at the tail; otherwise the naive
// `total_lines - area_height` formula under-counts rows and the
// viewport anchors too far up.
let tail_top = compute_tail_top_offset(&lines, area.height, width);
app.scroll.area_height = area.height;
let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width);
// `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
// math degenerates to index arithmetic.
let tail_top = lines.len().saturating_sub(inner.height as usize);
app.scroll.area_height = inner.height;
app.scroll.total_lines = lines.len();
app.scroll.tail_top_offset = tail_top;
app.scroll.turn_starts = turn_starts;
@ -150,62 +175,97 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.top_offset = app.scroll.top_offset.min(tail_top);
}
let visible = visible_slice(&lines, app.scroll.top_offset, area.height, width);
let end = (app.scroll.top_offset + inner.height as usize).min(lines.len());
let visible: Vec<Line<'static>> = lines[app.scroll.top_offset..end].to_vec();
// Pre-wrapped input → render without ratatui's word-wrap (which
// would otherwise re-wrap mid-row at word boundaries and desync the
// height count). The outer Block handles left/right padding
// uniformly for all rows.
Paragraph::new(visible)
.wrap(Wrap { trim: false })
.block(outer_block)
.render(area, frame.buffer_mut());
}
/// Smallest top offset that still keeps the last logical line on screen
/// once wrapping is applied. Walks the lines from the tail and counts
/// wrapped rows; returns the first line index that no longer fits.
fn compute_tail_top_offset(lines: &[Line<'_>], area_height: u16, width: u16) -> usize {
if lines.is_empty() || area_height == 0 {
return 0;
}
let mut used: u32 = 0;
let cap = area_height as u32;
for (i, line) in lines.iter().enumerate().rev() {
let h = wrapped_line_height(line, width) as u32;
if used + h > cap {
return i + 1;
}
used += h;
}
0
}
fn visible_slice(
lines: &[Line<'static>],
top_offset: usize,
area_height: u16,
width: u16,
) -> Vec<Line<'static>> {
if lines.is_empty() || area_height == 0 {
return Vec::new();
}
let mut out: Vec<Line<'static>> = Vec::new();
let mut used: u32 = 0;
for line in lines.iter().skip(top_offset) {
let h = wrapped_line_height(line, width) as u32;
if used + h > area_height as u32 {
break;
}
out.push(line.clone());
used += h;
if used >= area_height as u32 {
break;
}
}
out
}
fn wrapped_line_height(line: &Line, width: u16) -> u16 {
/// Split one logical line into one-terminal-row `Line`s via char-aware
/// wrapping. Preserves per-span styles and the source line's alignment.
fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>) {
if width == 0 {
return 1;
out.push(line);
return;
}
let w = line.width() as u16;
if w == 0 { 1 } else { w.div_ceil(width) }
let w = width as usize;
let alignment = line.alignment;
let mut current: Vec<Span<'static>> = Vec::new();
let mut row_width: usize = 0;
let mut pending = String::new();
let mut pending_width: usize = 0;
let mut pending_style = Style::default();
let commit_pending = |pending: &mut String,
pending_width: &mut usize,
pending_style: Style,
current: &mut Vec<Span<'static>>,
row_width: &mut usize| {
if pending.is_empty() {
return;
}
current.push(Span::styled(std::mem::take(pending), pending_style));
*row_width += *pending_width;
*pending_width = 0;
};
let push_row = |current: &mut Vec<Span<'static>>,
row_width: &mut usize,
out: &mut Vec<Line<'static>>| {
let mut l = Line::from(std::mem::take(current));
if let Some(a) = alignment {
l = l.alignment(a);
}
out.push(l);
*row_width = 0;
};
for span in line.spans {
if !pending.is_empty() && span.style != pending_style {
commit_pending(
&mut pending,
&mut pending_width,
pending_style,
&mut current,
&mut row_width,
);
}
pending_style = span.style;
for c in span.content.chars() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if row_width + pending_width + cw > w && (row_width + pending_width) > 0 {
commit_pending(
&mut pending,
&mut pending_width,
pending_style,
&mut current,
&mut row_width,
);
push_row(&mut current, &mut row_width, out);
}
pending.push(c);
pending_width += cw;
}
commit_pending(
&mut pending,
&mut pending_width,
pending_style,
&mut current,
&mut row_width,
);
}
// Always emit the final row (empty line stays empty).
push_row(&mut current, &mut row_width, out);
}
fn render_block_into(
@ -285,10 +345,7 @@ fn render_block_into(
fn push_padded_lines(lines: &mut Vec<Line<'static>>, text: &str, kind: MessageKind) {
let style = kind_style(kind);
for raw in text.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(raw.to_owned(), style),
]));
lines.push(Line::from(Span::styled(raw.to_owned(), style)));
}
if text.is_empty() {
lines.push(Line::from(""));
@ -311,20 +368,14 @@ fn push_padded_truncated(
let all: Vec<&str> = text.lines().collect();
let shown = all.len().min(NORMAL_MAX_LINES);
for raw in &all[..shown] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled((*raw).to_owned(), style),
]));
lines.push(Line::from(Span::styled((*raw).to_owned(), style)));
}
if all.len() > shown {
let hidden = all.len() - shown;
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("… +{hidden} more lines"),
Style::default().fg(Color::DarkGray),
),
]));
lines.push(Line::from(Span::styled(
format!("… +{hidden} more lines"),
Style::default().fg(Color::DarkGray),
)));
}
if text.is_empty() {
lines.push(Line::from(""));
@ -342,7 +393,7 @@ fn push_overview_line(
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let more = text.lines().count().saturating_sub(1);
let style = kind_style(kind);
let mut spans = vec![Span::raw(" ")];
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_owned(), style));
}