353 lines
9.8 KiB
Rust
353 lines
9.8 KiB
Rust
// src/textarea/widget.rs
|
|
#[cfg(feature = "gui")]
|
|
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::{Alignment, Rect},
|
|
style::Style,
|
|
text::{Line, Span},
|
|
widgets::{
|
|
Block, BorderType, Borders, Paragraph, StatefulWidget, Widget,
|
|
},
|
|
};
|
|
|
|
#[cfg(feature = "gui")]
|
|
use crate::data_provider::DataProvider;
|
|
|
|
#[cfg(feature = "gui")]
|
|
use crate::textarea::state::{
|
|
compute_h_scroll_with_padding,
|
|
count_wrapped_rows_indented,
|
|
TextAreaState,
|
|
TextOverflowMode,
|
|
};
|
|
|
|
#[cfg(feature = "gui")]
|
|
use unicode_width::UnicodeWidthChar;
|
|
|
|
#[cfg(feature = "gui")]
|
|
#[derive(Debug, Clone)]
|
|
pub struct TextArea<'a> {
|
|
pub(crate) block: Option<Block<'a>>,
|
|
pub(crate) style: Style,
|
|
pub(crate) border_type: BorderType,
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
impl<'a> Default for TextArea<'a> {
|
|
fn default() -> Self {
|
|
Self {
|
|
block: Some(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded),
|
|
),
|
|
style: Style::default(),
|
|
border_type: BorderType::Rounded,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
impl<'a> TextArea<'a> {
|
|
pub fn block(mut self, block: Block<'a>) -> Self {
|
|
self.block = Some(block);
|
|
self
|
|
}
|
|
|
|
pub fn style(mut self, style: Style) -> Self {
|
|
self.style = style;
|
|
self
|
|
}
|
|
|
|
pub fn border_type(mut self, ty: BorderType) -> Self {
|
|
self.border_type = ty;
|
|
if let Some(b) = &mut self.block {
|
|
*b = b.clone().border_type(ty);
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
fn display_width(s: &str) -> u16 {
|
|
s.chars()
|
|
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
|
|
.sum()
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
fn display_cols_up_to(s: &str, char_count: usize) -> u16 {
|
|
let mut cols: u16 = 0;
|
|
for (i, ch) in s.chars().enumerate() {
|
|
if i >= char_count {
|
|
break;
|
|
}
|
|
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
|
|
}
|
|
cols
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String {
|
|
if max_cols == 0 {
|
|
return String::new();
|
|
}
|
|
|
|
let mut current_cols: u16 = 0;
|
|
let mut output = String::new();
|
|
let mut taken: u16 = 0;
|
|
let mut started = false;
|
|
|
|
for ch in s.chars() {
|
|
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
|
|
|
|
if !started {
|
|
if current_cols.saturating_add(w) <= start_cols {
|
|
current_cols = current_cols.saturating_add(w);
|
|
continue;
|
|
} else {
|
|
started = true;
|
|
}
|
|
}
|
|
|
|
if taken.saturating_add(w) > max_cols {
|
|
break;
|
|
}
|
|
|
|
output.push(ch);
|
|
taken = taken.saturating_add(w);
|
|
current_cols = current_cols.saturating_add(w);
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
fn clip_window_with_indicator_padded(
|
|
text: &str,
|
|
view_width: u16,
|
|
indicator: char,
|
|
start_cols: u16,
|
|
) -> Line<'static> {
|
|
if view_width == 0 {
|
|
return Line::from("");
|
|
}
|
|
|
|
let total = display_width(text);
|
|
|
|
// Left indicator if we scrolled
|
|
let show_left = start_cols > 0;
|
|
let left_cols: u16 = if show_left { 1 } else { 0 };
|
|
|
|
// Capacity for text if we also need a right indicator
|
|
let cap_with_right = view_width.saturating_sub(left_cols + 1);
|
|
|
|
// Do we still have content beyond this window?
|
|
let remaining = total.saturating_sub(start_cols);
|
|
let show_right = remaining > cap_with_right;
|
|
|
|
// Final capacity for visible text
|
|
let max_visible = if show_right {
|
|
cap_with_right
|
|
} else {
|
|
view_width.saturating_sub(left_cols)
|
|
};
|
|
|
|
let visible = slice_by_display_cols(text, start_cols, max_visible);
|
|
|
|
let mut spans: Vec<Span> = Vec::new();
|
|
if show_left {
|
|
spans.push(Span::raw(indicator.to_string()));
|
|
}
|
|
|
|
// Visible text
|
|
spans.push(Span::raw(visible.clone()));
|
|
|
|
// Place $ flush-right
|
|
if show_right {
|
|
let used_cols = left_cols + display_width(&visible);
|
|
let right_pos = view_width.saturating_sub(1);
|
|
let filler = right_pos.saturating_sub(used_cols);
|
|
if filler > 0 {
|
|
spans.push(Span::raw(" ".repeat(filler as usize)));
|
|
}
|
|
spans.push(Span::raw(indicator.to_string()));
|
|
}
|
|
|
|
Line::from(spans)
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
fn wrap_segments_with_indent(
|
|
s: &str,
|
|
width: u16,
|
|
indent: u16,
|
|
) -> Vec<String> {
|
|
let mut segments: Vec<String> = Vec::new();
|
|
if width == 0 {
|
|
segments.push(String::new());
|
|
return segments;
|
|
}
|
|
|
|
let indent = indent.min(width.saturating_sub(1));
|
|
let cont_cap = width.saturating_sub(indent);
|
|
let indent_str = " ".repeat(indent as usize);
|
|
|
|
let mut buf = String::new();
|
|
let mut used: u16 = 0;
|
|
let mut first = true;
|
|
|
|
for ch in s.chars() {
|
|
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
|
|
let cap = if first { width } else { cont_cap };
|
|
|
|
// Early-wrap: wrap before filling the last cell (and avoid empty segment)
|
|
if used > 0 && used.saturating_add(w) >= cap {
|
|
segments.push(buf);
|
|
buf = String::new();
|
|
used = 0;
|
|
first = false;
|
|
if indent > 0 {
|
|
buf.push_str(&indent_str);
|
|
used = indent;
|
|
}
|
|
}
|
|
|
|
buf.push(ch);
|
|
used = used.saturating_add(w);
|
|
}
|
|
|
|
segments.push(buf);
|
|
segments
|
|
}
|
|
|
|
// Map visual row offset to (logical line, intra segment)
|
|
#[cfg(feature = "gui")]
|
|
fn resolve_start_line_and_intra_indented(
|
|
state: &TextAreaState,
|
|
inner: Rect,
|
|
) -> (usize, u16) {
|
|
let provider = state.editor.data_provider();
|
|
let total = provider.line_count();
|
|
|
|
if total == 0 {
|
|
return (0, 0);
|
|
}
|
|
|
|
let wrap = matches!(state.overflow_mode, TextOverflowMode::Wrap);
|
|
let width = inner.width;
|
|
let target_vis = state.scroll_y;
|
|
|
|
if !wrap {
|
|
let start = (target_vis as usize).min(total);
|
|
return (start, 0);
|
|
}
|
|
|
|
let indent = state.wrap_indent_cols;
|
|
|
|
let mut acc: u16 = 0;
|
|
for i in 0..total {
|
|
let s = provider.field_value(i);
|
|
let rows = count_wrapped_rows_indented(s, width, indent);
|
|
if acc.saturating_add(rows) > target_vis {
|
|
let intra = target_vis.saturating_sub(acc);
|
|
return (i, intra);
|
|
}
|
|
acc = acc.saturating_add(rows);
|
|
}
|
|
|
|
(total.saturating_sub(1), 0)
|
|
}
|
|
|
|
#[cfg(feature = "gui")]
|
|
impl<'a> StatefulWidget for TextArea<'a> {
|
|
type State = TextAreaState;
|
|
|
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
|
state.ensure_visible(area, self.block.as_ref());
|
|
|
|
let inner = if let Some(b) = &self.block {
|
|
b.clone().render(area, buf);
|
|
b.inner(area)
|
|
} else {
|
|
area
|
|
};
|
|
|
|
let edited_now = state.take_edited_flag();
|
|
|
|
let wrap_mode = matches!(state.overflow_mode, TextOverflowMode::Wrap);
|
|
let provider = state.editor.data_provider();
|
|
let total = provider.line_count();
|
|
|
|
let (start, intra) = resolve_start_line_and_intra_indented(state, inner);
|
|
|
|
let mut display_lines: Vec<Line> = Vec::new();
|
|
|
|
if total == 0 || start >= total {
|
|
if let Some(ph) = &state.placeholder {
|
|
display_lines.push(Line::from(Span::raw(ph.clone())));
|
|
}
|
|
} else if wrap_mode {
|
|
// manual pre-wrap path (unchanged)
|
|
let mut rows_left = inner.height;
|
|
let indent = state.wrap_indent_cols;
|
|
let mut i = start;
|
|
while i < total && rows_left > 0 {
|
|
let s = provider.field_value(i);
|
|
let segments = wrap_segments_with_indent(s, inner.width, indent);
|
|
let skip = if i == start { intra as usize } else { 0 };
|
|
for seg in segments.into_iter().skip(skip) {
|
|
display_lines.push(Line::from(Span::raw(seg)));
|
|
rows_left = rows_left.saturating_sub(1);
|
|
if rows_left == 0 {
|
|
break;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
} else {
|
|
// Indicator mode: full inner width; RIGHT_PAD only affects cursor clamp and h-scroll
|
|
let end = (start.saturating_add(inner.height as usize)).min(total);
|
|
|
|
for i in start..end {
|
|
let s = provider.field_value(i);
|
|
match state.overflow_mode {
|
|
TextOverflowMode::Wrap => unreachable!(),
|
|
TextOverflowMode::Indicator { ch } => {
|
|
let fits = display_width(s) <= inner.width;
|
|
|
|
let start_cols = if i == state.current_field() {
|
|
let col_idx = state.display_cursor_position();
|
|
let cursor_cols = display_cols_up_to(s, col_idx);
|
|
let (target_h, _left_cols) =
|
|
compute_h_scroll_with_padding(cursor_cols, inner.width);
|
|
|
|
if fits {
|
|
if edited_now { target_h } else { 0 }
|
|
} else {
|
|
target_h.max(state.h_scroll)
|
|
}
|
|
} else {
|
|
0
|
|
};
|
|
|
|
display_lines.push(clip_window_with_indicator_padded(
|
|
s,
|
|
inner.width,
|
|
ch,
|
|
start_cols,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let p = Paragraph::new(display_lines)
|
|
.alignment(Alignment::Left)
|
|
.style(self.style);
|
|
|
|
// No Paragraph::wrap/scroll in wrap mode — we pre-wrap.
|
|
p.render(inner, buf);
|
|
}
|
|
}
|