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

This commit is contained in:
Keisuke Hirata 2026-04-21 23:26:34 +09:00
parent 72128aab9f
commit d3ba0a299a
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)) format!("[tool] Read — {count} file{} read", plural(count))
}; };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(header, tool_style), Span::styled(header, tool_style),
])); ]));
@ -162,7 +161,6 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
if matches!(mode, Mode::Overview) { if matches!(mode, Mode::Overview) {
return vec![Line::from(vec![ return vec![Line::from(vec![
Span::raw(" "),
Span::styled("[tool] Write — ".to_owned(), tool_style), Span::styled("[tool] Write — ".to_owned(), tool_style),
Span::styled(format!("{label} "), label_style), Span::styled(format!("{label} "), label_style),
Span::styled(path, Style::default().fg(Color::White)), 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![ let mut lines = vec![
Line::from(vec![ Line::from(vec![
Span::raw(" "),
Span::styled("[tool] Write — ".to_owned(), tool_style), Span::styled("[tool] Write — ".to_owned(), tool_style),
Span::styled(format!("{label} "), label_style), Span::styled(format!("{label} "), label_style),
Span::styled(path.clone(), Style::default().fg(Color::White)), Span::styled(path.clone(), Style::default().fg(Color::White)),
@ -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 tool_style = Style::default().fg(Color::Cyan);
let header = Line::from(vec![ let header = Line::from(vec![
Span::raw(" "),
Span::styled(format!("[tool] Edit — {}", path), tool_style), Span::styled(format!("[tool] Edit — {}", path), tool_style),
Span::styled( Span::styled(
format!(" ({})", state_suffix(&tc.state)), format!(" ({})", state_suffix(&tc.state)),
@ -347,14 +343,12 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec<Line<'stati
.unwrap_or(state_suffix(&tc.state)) .unwrap_or(state_suffix(&tc.state))
.to_owned(); .to_owned();
return vec![Line::from(vec![ return vec![Line::from(vec![
Span::raw(" "),
Span::styled(format!("[tool] {label}"), tool_style), Span::styled(format!("[tool] {label}"), tool_style),
Span::styled(first, Style::default().fg(Color::White)), Span::styled(first, Style::default().fg(Color::White)),
])]; ])];
} }
let mut lines = vec![Line::from(vec![ let mut lines = vec![Line::from(vec![
Span::raw(" "),
Span::styled( Span::styled(
format!("[tool] {label}{}", state_suffix(&tc.state)), format!("[tool] {label}{}", state_suffix(&tc.state)),
tool_style, tool_style,
@ -408,13 +402,11 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
format!("[tool] {}{suffix}", tc.name) format!("[tool] {}{suffix}", tc.name)
}; };
return vec![Line::from(vec![ return vec![Line::from(vec![
Span::raw(" "),
Span::styled(label, tool_style), Span::styled(label, tool_style),
])]; ])];
} }
let mut lines = vec![Line::from(vec![ let mut lines = vec![Line::from(vec![
Span::raw(" "),
Span::styled( Span::styled(
format!("[tool] {}{}", tc.name, state_suffix(&tc.state)), format!("[tool] {}{}", tc.name, state_suffix(&tc.state)),
tool_style, tool_style,

View File

@ -18,6 +18,7 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
use unicode_width::UnicodeWidthChar;
use protocol::{Greeting, NotificationLevel}; use protocol::{Greeting, NotificationLevel};
@ -92,36 +93,58 @@ pub struct HistoryLayout {
} }
pub fn compute_history(app: &App, width: u16) -> HistoryLayout { pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
let mut lines: Vec<Line<'static>> = Vec::new(); // Step 1: collect logical lines from each block (unwrapped).
let mut turn_starts: Vec<usize> = Vec::new(); let mut logical: Vec<Line<'static>> = Vec::new();
let mut logical_turn_starts: Vec<usize> = Vec::new();
let mut first = true; let mut first = true;
let mut i = 0; let mut i = 0;
while i < app.blocks.len() { while i < app.blocks.len() {
if !first { if !first {
lines.push(Line::from("")); logical.push(Line::from(""));
} }
first = false; first = false;
let block = &app.blocks[i]; let block = &app.blocks[i];
if matches!(block, Block::TurnHeader { .. }) { 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(_)) { if matches!(block, Block::ToolCall(_)) {
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode); 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); i += out.consumed.max(1);
continue; continue;
} }
render_block_into(&mut lines, block, width, app.mode); render_block_into(&mut logical, block, width, app.mode);
i += 1; 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 } HistoryLayout { lines, turn_starts }
} }
/// Maximum body lines a normal-mode block may emit before truncation. /// Maximum body lines a normal-mode block may emit before truncation.
const NORMAL_MAX_LINES: usize = 6; 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) { fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
if area.height == 0 || area.width == 0 { if area.height == 0 || area.width == 0 {
app.scroll.area_height = area.height; 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(); app.scroll.turn_starts.clear();
return; return;
} }
let width = area.width; let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let HistoryLayout { lines, turn_starts } = compute_history(app, width); let inner = outer_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
// Cache for key handlers. Computing `tail_top_offset` wrap-aware let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width);
// — i.e. in post-wrap terminal rows — is what keeps long CJK
// responses visible at the tail; otherwise the naive // `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
// `total_lines - area_height` formula under-counts rows and the // math degenerates to index arithmetic.
// viewport anchors too far up. let tail_top = lines.len().saturating_sub(inner.height as usize);
let tail_top = compute_tail_top_offset(&lines, area.height, width); app.scroll.area_height = inner.height;
app.scroll.area_height = area.height;
app.scroll.total_lines = lines.len(); app.scroll.total_lines = lines.len();
app.scroll.tail_top_offset = tail_top; app.scroll.tail_top_offset = tail_top;
app.scroll.turn_starts = turn_starts; 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); 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) Paragraph::new(visible)
.wrap(Wrap { trim: false }) .block(outer_block)
.render(area, frame.buffer_mut()); .render(area, frame.buffer_mut());
} }
/// Smallest top offset that still keeps the last logical line on screen /// Split one logical line into one-terminal-row `Line`s via char-aware
/// once wrapping is applied. Walks the lines from the tail and counts /// wrapping. Preserves per-span styles and the source line's alignment.
/// wrapped rows; returns the first line index that no longer fits. fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>) {
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 {
if width == 0 { if width == 0 {
return 1; out.push(line);
return;
} }
let w = line.width() as u16; let w = width as usize;
if w == 0 { 1 } else { w.div_ceil(width) } 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( 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) { fn push_padded_lines(lines: &mut Vec<Line<'static>>, text: &str, kind: MessageKind) {
let style = kind_style(kind); let style = kind_style(kind);
for raw in text.lines() { for raw in text.lines() {
lines.push(Line::from(vec![ lines.push(Line::from(Span::styled(raw.to_owned(), style)));
Span::raw(" "),
Span::styled(raw.to_owned(), style),
]));
} }
if text.is_empty() { if text.is_empty() {
lines.push(Line::from("")); lines.push(Line::from(""));
@ -311,20 +368,14 @@ fn push_padded_truncated(
let all: Vec<&str> = text.lines().collect(); let all: Vec<&str> = text.lines().collect();
let shown = all.len().min(NORMAL_MAX_LINES); let shown = all.len().min(NORMAL_MAX_LINES);
for raw in &all[..shown] { for raw in &all[..shown] {
lines.push(Line::from(vec![ lines.push(Line::from(Span::styled((*raw).to_owned(), style)));
Span::raw(" "),
Span::styled((*raw).to_owned(), style),
]));
} }
if all.len() > shown { if all.len() > shown {
let hidden = all.len() - shown; let hidden = all.len() - shown;
lines.push(Line::from(vec![ lines.push(Line::from(Span::styled(
Span::raw(" "),
Span::styled(
format!("… +{hidden} more lines"), format!("… +{hidden} more lines"),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
), )));
]));
} }
if text.is_empty() { if text.is_empty() {
lines.push(Line::from("")); 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 first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let more = text.lines().count().saturating_sub(1); let more = text.lines().count().saturating_sub(1);
let style = kind_style(kind); let style = kind_style(kind);
let mut spans = vec![Span::raw(" ")]; let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() { if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_owned(), style)); spans.push(Span::styled(prefix.to_owned(), style));
} }