改行テキストの行計算・Padding設定
This commit is contained in:
parent
72128aab9f
commit
d3ba0a299a
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user