mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/image): render view image as card
This commit is contained in:
@@ -1,5 +1,22 @@
|
|||||||
|
use super::card_style::{
|
||||||
|
ansi16_inverse_color,
|
||||||
|
browser_card_style,
|
||||||
|
fill_card_background,
|
||||||
|
hint_text_style,
|
||||||
|
primary_text_style,
|
||||||
|
rows_to_lines,
|
||||||
|
secondary_text_style,
|
||||||
|
title_text_style,
|
||||||
|
truncate_with_ellipsis,
|
||||||
|
CardRow,
|
||||||
|
CardSegment,
|
||||||
|
CardStyle,
|
||||||
|
CARD_ACCENT_WIDTH,
|
||||||
|
};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::colors;
|
||||||
use crate::history::state::ImageRecord;
|
use crate::history::state::ImageRecord;
|
||||||
|
use crate::theme::{palette_mode, PaletteMode};
|
||||||
use code_protocol::num_format::format_with_separators;
|
use code_protocol::num_format::format_with_separators;
|
||||||
use ::image::ImageReader;
|
use ::image::ImageReader;
|
||||||
use ::image::image_dimensions;
|
use ::image::image_dimensions;
|
||||||
@@ -11,6 +28,29 @@ use std::cell::RefCell;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
const BORDER_TOP: &str = "╭─";
|
||||||
|
const BORDER_BODY: &str = "│";
|
||||||
|
const BORDER_BOTTOM: &str = "╰─";
|
||||||
|
|
||||||
|
const DEFAULT_TEXT_INDENT: usize = 2;
|
||||||
|
const TEXT_RIGHT_PADDING: usize = 2;
|
||||||
|
const IMAGE_GAP: usize = 2;
|
||||||
|
const IMAGE_LEFT_PAD: usize = 1;
|
||||||
|
const IMAGE_MIN_WIDTH: usize = 18;
|
||||||
|
const IMAGE_MAX_WIDTH: usize = 64;
|
||||||
|
const MIN_TEXT_WIDTH: usize = 28;
|
||||||
|
const MIN_IMAGE_ROWS: usize = 4;
|
||||||
|
const MAX_IMAGE_ROWS: usize = 60;
|
||||||
|
const HINT_TEXT: &str = "Image output";
|
||||||
|
|
||||||
|
struct ImagePreviewLayout {
|
||||||
|
start_row: usize,
|
||||||
|
height_rows: usize,
|
||||||
|
width_cols: usize,
|
||||||
|
indent_cols: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct ImageOutputCell {
|
pub(crate) struct ImageOutputCell {
|
||||||
record: ImageRecord,
|
record: ImageRecord,
|
||||||
@@ -52,27 +92,491 @@ impl ImageOutputCell {
|
|||||||
picker_ref.as_ref().unwrap().clone()
|
picker_ref.as_ref().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_rows_for_width(&self, width: u16) -> Option<u16> {
|
fn accent_style(style: &CardStyle) -> Style {
|
||||||
const MIN_IMAGE_ROWS: usize = 4;
|
if palette_mode() == PaletteMode::Ansi16 {
|
||||||
const MAX_IMAGE_ROWS: usize = 60;
|
return Style::default().fg(ansi16_inverse_color());
|
||||||
let path = self.image_path()?;
|
}
|
||||||
if width == 0 || !path.exists() {
|
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
|
||||||
|
Style::default().fg(dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_label(&self) -> String {
|
||||||
|
if let Some(alt) = self
|
||||||
|
.record
|
||||||
|
.alt_text
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return alt.to_string();
|
||||||
|
}
|
||||||
|
if let Some(path) = self.record.source_path.as_ref() {
|
||||||
|
if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
return name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.display().to_string();
|
||||||
|
}
|
||||||
|
"Image".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_title_text(&self) -> String {
|
||||||
|
let label = self.display_label();
|
||||||
|
if label.eq_ignore_ascii_case("image") {
|
||||||
|
"Image".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Image: {label}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_lines(&self) -> Vec<String> {
|
||||||
|
let record = &self.record;
|
||||||
|
let width = record.width;
|
||||||
|
let height = record.height;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(format!("Dimensions: {width}x{height} px"));
|
||||||
|
if let Some(mime) = record
|
||||||
|
.mime_type
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
lines.push(format!("Type: {mime}"));
|
||||||
|
}
|
||||||
|
if let Some(byte_len) = record.byte_len {
|
||||||
|
let size = format_with_separators(u64::from(byte_len));
|
||||||
|
lines.push(format!("Size: {size} bytes"));
|
||||||
|
}
|
||||||
|
if let Some(alt) = record
|
||||||
|
.alt_text
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
lines.push(format!("Alt: {alt}"));
|
||||||
|
}
|
||||||
|
if let Some(path) = record.source_path.as_ref() {
|
||||||
|
lines.push(format!("Source: {}", path.display()));
|
||||||
|
}
|
||||||
|
if let Some(hash) = record.sha256.as_ref() {
|
||||||
|
let short = if hash.len() > 12 {
|
||||||
|
format!("{}…", &hash[..12])
|
||||||
|
} else {
|
||||||
|
hash.clone()
|
||||||
|
};
|
||||||
|
lines.push(format!("SHA256: {short}"));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn top_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
if body_width == 0 {
|
||||||
|
return CardRow::new(
|
||||||
|
BORDER_TOP.to_string(),
|
||||||
|
Self::accent_style(style),
|
||||||
|
segments,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_style = if palette_mode() == PaletteMode::Ansi16 {
|
||||||
|
Style::default().fg(ansi16_inverse_color())
|
||||||
|
} else {
|
||||||
|
title_text_style(style)
|
||||||
|
};
|
||||||
|
|
||||||
|
segments.push(CardSegment::new(" ".to_string(), title_style));
|
||||||
|
let remaining = body_width.saturating_sub(1);
|
||||||
|
let text = truncate_with_ellipsis(self.header_title_text().as_str(), remaining);
|
||||||
|
if !text.is_empty() {
|
||||||
|
segments.push(CardSegment::new(text, title_style));
|
||||||
|
}
|
||||||
|
CardRow::new(BORDER_TOP.to_string(), Self::accent_style(style), segments, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
|
||||||
|
CardRow::new(
|
||||||
|
BORDER_BODY.to_string(),
|
||||||
|
Self::accent_style(style),
|
||||||
|
vec![CardSegment::new(" ".repeat(body_width), Style::default())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_text_row(
|
||||||
|
&self,
|
||||||
|
text: impl Into<String>,
|
||||||
|
body_width: usize,
|
||||||
|
style: &CardStyle,
|
||||||
|
text_style: Style,
|
||||||
|
indent_cols: usize,
|
||||||
|
right_padding_cols: usize,
|
||||||
|
) -> CardRow {
|
||||||
|
if body_width == 0 {
|
||||||
|
return CardRow::new(BORDER_BODY.to_string(), Self::accent_style(style), Vec::new(), None);
|
||||||
|
}
|
||||||
|
let indent = indent_cols.min(body_width.saturating_sub(1));
|
||||||
|
let available = body_width.saturating_sub(indent);
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
if indent > 0 {
|
||||||
|
segments.push(CardSegment::new(" ".repeat(indent), Style::default()));
|
||||||
|
}
|
||||||
|
let text: String = text.into();
|
||||||
|
if available == 0 {
|
||||||
|
return CardRow::new(BORDER_BODY.to_string(), Self::accent_style(style), segments, None);
|
||||||
|
}
|
||||||
|
let usable_width = available.saturating_sub(right_padding_cols);
|
||||||
|
let display = if usable_width == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
truncate_with_ellipsis(text.as_str(), usable_width)
|
||||||
|
};
|
||||||
|
segments.push(CardSegment::new(display, text_style));
|
||||||
|
if right_padding_cols > 0 && available > 0 {
|
||||||
|
let pad = right_padding_cols.min(available);
|
||||||
|
segments.push(CardSegment::new(" ".repeat(pad), Style::default()));
|
||||||
|
}
|
||||||
|
CardRow::new(BORDER_BODY.to_string(), Self::accent_style(style), segments, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
|
||||||
|
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
|
||||||
|
let hint_style = if palette_mode() == PaletteMode::Ansi16 {
|
||||||
|
Style::default().fg(ansi16_inverse_color())
|
||||||
|
} else {
|
||||||
|
hint_text_style(style)
|
||||||
|
};
|
||||||
|
let segment = CardSegment::new(text, hint_style);
|
||||||
|
CardRow::new(
|
||||||
|
BORDER_BOTTOM.to_string(),
|
||||||
|
Self::accent_style(style),
|
||||||
|
vec![segment],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card_rows(
|
||||||
|
&self,
|
||||||
|
width: u16,
|
||||||
|
style: &CardStyle,
|
||||||
|
) -> (Vec<CardRow>, Option<ImagePreviewLayout>) {
|
||||||
|
if width == 0 {
|
||||||
|
return (Vec::new(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accent_width = CARD_ACCENT_WIDTH.min(width as usize);
|
||||||
|
let body_width = width
|
||||||
|
.saturating_sub(accent_width as u16)
|
||||||
|
.saturating_sub(1) as usize;
|
||||||
|
if body_width == 0 {
|
||||||
|
return (Vec::new(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: Vec<CardRow> = Vec::new();
|
||||||
|
rows.push(self.top_border_row(body_width, style));
|
||||||
|
rows.push(self.blank_border_row(body_width, style));
|
||||||
|
|
||||||
|
let mut image_layout = self.compute_image_layout(body_width);
|
||||||
|
let indent_cols = image_layout
|
||||||
|
.as_ref()
|
||||||
|
.map(|layout| layout.indent_cols)
|
||||||
|
.unwrap_or(DEFAULT_TEXT_INDENT);
|
||||||
|
let indent_cols = indent_cols.min(body_width.saturating_sub(1));
|
||||||
|
let right_padding = TEXT_RIGHT_PADDING.min(body_width);
|
||||||
|
|
||||||
|
let content_start = rows.len();
|
||||||
|
|
||||||
|
rows.push(self.body_text_row(
|
||||||
|
"Details",
|
||||||
|
body_width,
|
||||||
|
style,
|
||||||
|
primary_text_style(style),
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
));
|
||||||
|
|
||||||
|
let details = self.metadata_lines();
|
||||||
|
if details.is_empty() {
|
||||||
|
for wrapped in wrap_card_lines(
|
||||||
|
"No image metadata available",
|
||||||
|
body_width,
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
) {
|
||||||
|
rows.push(self.body_text_row(
|
||||||
|
wrapped,
|
||||||
|
body_width,
|
||||||
|
style,
|
||||||
|
secondary_text_style(style),
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for detail in details {
|
||||||
|
for wrapped in wrap_card_lines(
|
||||||
|
detail.as_str(),
|
||||||
|
body_width,
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
) {
|
||||||
|
rows.push(self.body_text_row(
|
||||||
|
wrapped,
|
||||||
|
body_width,
|
||||||
|
style,
|
||||||
|
secondary_text_style(style),
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(layout) = image_layout.as_mut() {
|
||||||
|
layout.start_row = content_start;
|
||||||
|
let existing = rows.len().saturating_sub(content_start);
|
||||||
|
if existing < layout.height_rows {
|
||||||
|
let missing = layout.height_rows - existing;
|
||||||
|
for _ in 0..missing {
|
||||||
|
rows.push(self.body_text_row(
|
||||||
|
"",
|
||||||
|
body_width,
|
||||||
|
style,
|
||||||
|
Style::default(),
|
||||||
|
indent_cols,
|
||||||
|
right_padding,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(self.blank_border_row(body_width, style));
|
||||||
|
rows.push(self.bottom_border_row(body_width, style));
|
||||||
|
|
||||||
|
(rows, image_layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_image_layout(&self, body_width: usize) -> Option<ImagePreviewLayout> {
|
||||||
|
if self.image_path().is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body_width
|
||||||
|
< IMAGE_LEFT_PAD + IMAGE_MIN_WIDTH + IMAGE_GAP + MIN_TEXT_WIDTH + TEXT_RIGHT_PADDING
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_image = body_width
|
||||||
|
.saturating_sub(IMAGE_LEFT_PAD + MIN_TEXT_WIDTH + IMAGE_GAP + TEXT_RIGHT_PADDING);
|
||||||
|
if max_image < IMAGE_MIN_WIDTH {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut image_cols = max_image;
|
||||||
|
if image_cols > IMAGE_MAX_WIDTH {
|
||||||
|
image_cols = IMAGE_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
if image_cols < IMAGE_MIN_WIDTH {
|
||||||
|
image_cols = IMAGE_MIN_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = self.compute_image_rows(image_cols)?;
|
||||||
|
Some(ImagePreviewLayout {
|
||||||
|
start_row: 0,
|
||||||
|
height_rows: rows,
|
||||||
|
width_cols: image_cols,
|
||||||
|
indent_cols: IMAGE_LEFT_PAD + image_cols + IMAGE_GAP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_image_rows(&self, image_cols: usize) -> Option<usize> {
|
||||||
|
if image_cols == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = self.image_path()?;
|
||||||
let picker = self.ensure_picker();
|
let picker = self.ensure_picker();
|
||||||
let (cell_w, cell_h) = picker.font_size();
|
let (cell_w, cell_h) = picker.font_size();
|
||||||
if cell_w == 0 || cell_h == 0 {
|
if cell_w == 0 || cell_h == 0 {
|
||||||
return Some(MIN_IMAGE_ROWS as u16);
|
return Some(MIN_IMAGE_ROWS);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (img_w, img_h) = match image_dimensions(path) {
|
let (img_w, img_h) = match image_dimensions(path) {
|
||||||
Ok((w, h)) if w > 0 && h > 0 => (w, h),
|
Ok((w, h)) if w > 0 && h > 0 => (w, h),
|
||||||
_ => return Some(MIN_IMAGE_ROWS as u16),
|
_ => return Some(MIN_IMAGE_ROWS),
|
||||||
};
|
};
|
||||||
let cols = width as u32;
|
|
||||||
|
let cols = image_cols as u32;
|
||||||
let rows_by_w = (cols * cell_w as u32 * img_h) as f64
|
let rows_by_w = (cols * cell_w as u32 * img_h) as f64
|
||||||
/ (img_w * cell_h as u32) as f64;
|
/ (img_w * cell_h as u32) as f64;
|
||||||
let rows = rows_by_w.ceil().max(1.0) as usize;
|
let rows = rows_by_w.ceil().max(1.0) as usize;
|
||||||
Some(rows.clamp(MIN_IMAGE_ROWS, MAX_IMAGE_ROWS) as u16)
|
Some(rows.clamp(MIN_IMAGE_ROWS, MAX_IMAGE_ROWS))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_card(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||||
|
if area.width <= 2 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = browser_card_style();
|
||||||
|
let draw_width = area.width - 2;
|
||||||
|
let render_area = Rect {
|
||||||
|
width: draw_width,
|
||||||
|
..area
|
||||||
|
};
|
||||||
|
|
||||||
|
fill_card_background(buf, render_area, &style);
|
||||||
|
let (rows, preview_layout) = self.build_card_rows(render_area.width, &style);
|
||||||
|
if rows.is_empty() {
|
||||||
|
self.render_plain_summary(area, buf, skip_rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lines = rows_to_lines(&rows, &style, render_area.width);
|
||||||
|
let text = Text::from(lines);
|
||||||
|
|
||||||
|
Paragraph::new(text)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((skip_rows, 0))
|
||||||
|
.render(render_area, buf);
|
||||||
|
|
||||||
|
if let Some(layout) = preview_layout.as_ref() {
|
||||||
|
if let Some(path) = self.image_path() {
|
||||||
|
self.render_image_preview(render_area, buf, skip_rows, layout, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let clear_start = area.x + draw_width;
|
||||||
|
let clear_end = area.x + area.width;
|
||||||
|
for x in clear_start..clear_end {
|
||||||
|
for row in 0..area.height {
|
||||||
|
let cell = &mut buf[(x, area.y + row)];
|
||||||
|
cell.set_symbol(" ");
|
||||||
|
cell.set_bg(crate::colors::background());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_image_preview(
|
||||||
|
&self,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
skip_rows: u16,
|
||||||
|
layout: &ImagePreviewLayout,
|
||||||
|
path: &Path,
|
||||||
|
) {
|
||||||
|
let accent_width = CARD_ACCENT_WIDTH.min(area.width as usize) as u16;
|
||||||
|
if accent_width >= area.width {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewport_top = skip_rows as usize;
|
||||||
|
let viewport_bottom = viewport_top + area.height as usize;
|
||||||
|
let shot_top = layout.start_row;
|
||||||
|
let shot_bottom = layout.start_row + layout.height_rows;
|
||||||
|
|
||||||
|
if shot_bottom <= viewport_top || shot_top >= viewport_bottom {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_top = shot_top.max(viewport_top);
|
||||||
|
let visible_bottom = shot_bottom.min(viewport_bottom);
|
||||||
|
if visible_bottom <= visible_top {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_width = area.width.saturating_sub(accent_width);
|
||||||
|
if body_width == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left_pad = IMAGE_LEFT_PAD.min(body_width as usize) as u16;
|
||||||
|
if body_width <= left_pad {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usable_width = body_width.saturating_sub(left_pad);
|
||||||
|
let image_width = layout.width_cols.min(usable_width as usize) as u16;
|
||||||
|
if image_width == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows_to_copy = (visible_bottom - visible_top) as u16;
|
||||||
|
if rows_to_copy == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest_x = area.x + accent_width + left_pad;
|
||||||
|
let dest_y = area.y + (visible_top - viewport_top) as u16;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
let placeholder_area = Rect { x: dest_x, y: dest_y, width: image_width, height: rows_to_copy };
|
||||||
|
self.render_image_placeholder(path, placeholder_area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_height = layout.height_rows as u16;
|
||||||
|
if full_height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offscreen = match self.render_image_buffer(path, image_width, full_height) {
|
||||||
|
Ok(buffer) => buffer,
|
||||||
|
Err(_) => {
|
||||||
|
let placeholder_area = Rect { x: dest_x, y: dest_y, width: image_width, height: rows_to_copy };
|
||||||
|
self.render_image_placeholder(path, placeholder_area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let src_start_row = (visible_top - shot_top) as u16;
|
||||||
|
let area_bottom = area.y + area.height;
|
||||||
|
let area_right = area.x + area.width;
|
||||||
|
|
||||||
|
for row in 0..rows_to_copy {
|
||||||
|
let dest_row = dest_y + row;
|
||||||
|
if dest_row >= area_bottom {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let src_row = src_start_row + row;
|
||||||
|
for col in 0..image_width {
|
||||||
|
let dest_col = dest_x + col;
|
||||||
|
if dest_col >= area_right {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(src_cell) = offscreen.cell((col, src_row)) else { continue; };
|
||||||
|
if let Some(dest_cell) = buf.cell_mut((dest_col, dest_row)) {
|
||||||
|
*dest_cell = src_cell.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_image_placeholder(&self, path: &Path, area: Rect, buf: &mut Buffer) {
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::widgets::{Block, Borders};
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("image");
|
||||||
|
let placeholder_text = format!("Image:\n{filename}");
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(colors::info()))
|
||||||
|
.title("Image");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
Paragraph::new(placeholder_text)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(colors::text_dim())
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.render(inner, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_image_buffer(&self, path: &Path, width: u16, height: u16) -> Result<Buffer, ()> {
|
fn render_image_buffer(&self, path: &Path, width: u16, height: u16) -> Result<Buffer, ()> {
|
||||||
@@ -112,9 +616,9 @@ impl ImageOutputCell {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_text_only(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
fn render_plain_summary(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||||
let cell_bg = crate::colors::background();
|
let cell_bg = colors::background();
|
||||||
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
|
let bg_style = Style::default().bg(cell_bg).fg(colors::text());
|
||||||
fill_rect(buf, area, Some(' '), bg_style);
|
fill_rect(buf, area, Some(' '), bg_style);
|
||||||
let lines = self.display_lines_trimmed();
|
let lines = self.display_lines_trimmed();
|
||||||
let text = Text::from(lines);
|
let text = Text::from(lines);
|
||||||
@@ -140,108 +644,129 @@ impl HistoryCell for ImageOutputCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||||
let record = &self.record;
|
let label = self.display_label();
|
||||||
let mut descriptors = vec![format!("{}x{} px", record.width, record.height)];
|
let summary = if label.eq_ignore_ascii_case("image") {
|
||||||
if let Some(mime) = &record.mime_type {
|
"Image output".to_string()
|
||||||
descriptors.push(mime.clone());
|
} else {
|
||||||
}
|
format!("Image output: {label}")
|
||||||
if let Some(byte_len) = record.byte_len {
|
};
|
||||||
descriptors.push(format!(
|
|
||||||
"{} bytes",
|
|
||||||
format_with_separators(u64::from(byte_len))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let summary = format!("tool result ({})", descriptors.join(", "));
|
|
||||||
|
|
||||||
let mut lines = vec![Line::from(summary)];
|
let mut lines = vec![Line::from(summary)];
|
||||||
if let Some(alt) = record.alt_text.as_ref() {
|
for line in self.metadata_lines() {
|
||||||
if !alt.is_empty() {
|
lines.push(Line::from(line));
|
||||||
lines.push(Line::from(format!("alt: {alt}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(path) = record.source_path.as_ref() {
|
|
||||||
lines.push(Line::from(format!("source: {}", path.display())));
|
|
||||||
}
|
|
||||||
if let Some(hash) = record.sha256.as_ref() {
|
|
||||||
let short = if hash.len() > 12 {
|
|
||||||
format!("{}…", &hash[..12])
|
|
||||||
} else {
|
|
||||||
hash.clone()
|
|
||||||
};
|
|
||||||
lines.push(Line::from(format!("sha256: {short}")));
|
|
||||||
}
|
}
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desired_height(&self, width: u16) -> u16 {
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
let image_rows = self.image_rows_for_width(width).unwrap_or(0);
|
let style = browser_card_style();
|
||||||
let lines = self.display_lines_trimmed();
|
let trimmed_width = width.saturating_sub(2);
|
||||||
let text_height = Paragraph::new(Text::from(lines))
|
if trimmed_width == 0 {
|
||||||
.wrap(Wrap { trim: false })
|
return 0;
|
||||||
.line_count(width) as u16;
|
}
|
||||||
image_rows.saturating_add(text_height)
|
let (rows, _) = self.build_card_rows(trimmed_width, &style);
|
||||||
|
rows.len().max(1) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_custom_render(&self) -> bool {
|
fn has_custom_render(&self) -> bool {
|
||||||
self.image_path().is_some()
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||||
let Some(path) = self.image_path() else {
|
self.render_card(area, buf, skip_rows);
|
||||||
self.render_text_only(area, buf, skip_rows);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(image_rows) = self.image_rows_for_width(area.width) else {
|
|
||||||
self.render_text_only(area, buf, skip_rows);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cell_bg = crate::colors::background();
|
|
||||||
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
|
|
||||||
fill_rect(buf, area, Some(' '), bg_style);
|
|
||||||
|
|
||||||
let mut image_visible_rows = 0u16;
|
|
||||||
if skip_rows < image_rows {
|
|
||||||
image_visible_rows = (image_rows - skip_rows).min(area.height);
|
|
||||||
let offscreen = match self.render_image_buffer(path, area.width, image_rows) {
|
|
||||||
Ok(buffer) => buffer,
|
|
||||||
Err(_) => {
|
|
||||||
self.render_text_only(area, buf, skip_rows);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let src_start = skip_rows;
|
|
||||||
for row in 0..image_visible_rows {
|
|
||||||
let dest_row = area.y + row;
|
|
||||||
let src_row = src_start + row;
|
|
||||||
for col in 0..area.width {
|
|
||||||
let dest_col = area.x + col;
|
|
||||||
let Some(src_cell) = offscreen.cell((col, src_row)) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(dest_cell) = buf.cell_mut((dest_col, dest_row)) {
|
|
||||||
*dest_cell = src_cell.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if area.height > image_visible_rows {
|
|
||||||
let text_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y + image_visible_rows,
|
|
||||||
width: area.width,
|
|
||||||
height: area.height - image_visible_rows,
|
|
||||||
};
|
|
||||||
let text_scroll = skip_rows.saturating_sub(image_rows);
|
|
||||||
let lines = self.display_lines_trimmed();
|
|
||||||
let text = Text::from(lines);
|
|
||||||
Paragraph::new(text)
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.scroll((text_scroll, 0))
|
|
||||||
.style(Style::default().bg(cell_bg))
|
|
||||||
.render(text_area, buf);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wrap_card_lines(text: &str, body_width: usize, indent_cols: usize, right_padding: usize) -> Vec<String> {
|
||||||
|
let available = body_width
|
||||||
|
.saturating_sub(indent_cols)
|
||||||
|
.saturating_sub(right_padding);
|
||||||
|
if available == 0 {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
wrap_line_to_width(text, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_line_to_width(text: &str, width: usize) -> Vec<String> {
|
||||||
|
if width == 0 {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut current_width = 0;
|
||||||
|
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let mut word_parts = if string_display_width(word) > width {
|
||||||
|
split_long_card_word(word, width)
|
||||||
|
} else {
|
||||||
|
vec![word.to_string()]
|
||||||
|
};
|
||||||
|
|
||||||
|
for part in word_parts.drain(..) {
|
||||||
|
let part_width = string_display_width(part.as_str());
|
||||||
|
if current.is_empty() {
|
||||||
|
current.push_str(part.as_str());
|
||||||
|
current_width = part_width;
|
||||||
|
} else if current_width + 1 + part_width > width {
|
||||||
|
lines.push(current);
|
||||||
|
current = part.clone();
|
||||||
|
current_width = part_width;
|
||||||
|
} else {
|
||||||
|
current.push(' ');
|
||||||
|
current.push_str(part.as_str());
|
||||||
|
current_width += 1 + part_width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.is_empty() {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_long_card_word(word: &str, width: usize) -> Vec<String> {
|
||||||
|
if width == 0 {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut current_width = 0;
|
||||||
|
|
||||||
|
for ch in word.chars() {
|
||||||
|
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1);
|
||||||
|
if current_width + ch_width > width && !current.is_empty() {
|
||||||
|
parts.push(current);
|
||||||
|
current = String::new();
|
||||||
|
current_width = 0;
|
||||||
|
}
|
||||||
|
current.push(ch);
|
||||||
|
current_width += ch_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.is_empty() {
|
||||||
|
parts.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
parts.push(String::new());
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_display_width(text: &str) -> usize {
|
||||||
|
text
|
||||||
|
.chars()
|
||||||
|
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user