use super::{Composer, ComposerLayout};
use crate::attachment::{AttachmentKind, classify_attachment};
use crate::theme::Theme;
use crate::view::widgets::RowsView;
use crate::view::wrap::{fit_prefix, wrap_text_char};
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use std::ops::Range;
use unicode_width::UnicodeWidthStr;
const PREFIX_WIDTH: u16 = 2;
pub(super) struct InputLayout {
pub rows: Vec<Range<usize>>,
pub cursor_row: usize,
pub cursor_column: usize,
}
pub(super) fn input_layout(text: &str, cursor_byte: usize, content_width: usize) -> InputLayout {
let mut rows = Vec::new();
let mut offset = 0;
for line in text.split('\n') {
for chunk in wrap_text_char(line, content_width) {
rows.push(offset..offset + chunk.len());
offset += chunk.len();
}
offset += 1;
}
let index = rows.iter().position(|row| cursor_byte <= row.end).unwrap_or(rows.len().saturating_sub(1));
let column = text[rows[index].start..cursor_byte].width();
if column < content_width {
return InputLayout { rows, cursor_row: index, cursor_column: column };
}
if rows.get(index + 1).is_none_or(|next| next.start != rows[index].end) {
rows.insert(index + 1, cursor_byte..cursor_byte);
}
InputLayout { rows, cursor_row: index + 1, cursor_column: 0 }
}
impl InputLayout {
pub(super) fn byte_at(&self, text: &str, row: usize, column: usize) -> Option<usize> {
let row = self.rows.get(row)?;
Some(row.start + fit_prefix(&text[row.clone()], column).0)
}
}
pub(crate) struct ComposerBodyView<'a> {
layout: &'a ComposerLayout,
first_row: usize,
}
impl<'a> ComposerBodyView<'a> {
pub(crate) fn new(layout: &'a ComposerLayout, first_row: usize) -> Self {
Self { layout, first_row }
}
pub(crate) fn cursor_position(&self, area: Rect) -> Option<Position> {
let cursor = self.layout.cursor;
if usize::from(cursor.y) < self.first_row {
return None;
}
let x = area.x.saturating_add(cursor.x);
let y = area.y.saturating_add(u16::try_from(usize::from(cursor.y) - self.first_row).unwrap_or(u16::MAX));
(x < area.right() && y < area.bottom()).then_some(Position::new(x, y))
}
}
impl Widget for ComposerBodyView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
RowsView::new(&self.layout.lines[self.first_row.min(self.layout.lines.len())..]).render(area, buf);
}
}
impl Composer {
pub fn on_resize(&mut self, width: u16) {
self.content_width = Some(usize::from(width.saturating_sub(PREFIX_WIDTH).max(1)));
}
pub fn layout(&self, width: u16, theme: &Theme) -> ComposerLayout {
let content_width = usize::from(width.saturating_sub(PREFIX_WIDTH).max(1));
let rule = Line::styled("─".repeat(usize::from(width)), Style::new().fg(theme.muted));
let mut lines = vec![rule.clone()];
let cursor = self.push_input_lines(&mut lines, content_width, theme);
lines.extend(self.pending_media().iter().map(|attachment| {
let label = match classify_attachment(&attachment.path) {
AttachmentKind::Image => "image",
AttachmentKind::Audio => "audio",
_ => "file",
};
Line::styled(format!(" attached {label}: {}", attachment.display_name), Style::new().fg(theme.info))
}));
lines.push(rule);
ComposerLayout { lines, cursor }
}
fn push_input_lines(&self, lines: &mut Vec<Line<'static>>, content_width: usize, theme: &Theme) -> Position {
let text = self.text();
let mentions: Vec<Range<usize>> = text
.match_indices('@')
.filter_map(|(at_pos, _)| {
let end = text[at_pos..].find(char::is_whitespace).map_or(text.len(), |offset| at_pos + offset);
(end > at_pos).then_some(at_pos..end)
})
.collect();
let layout = input_layout(text, self.buffer.cursor(), content_width);
let first_row = u16::try_from(lines.len()).unwrap_or(u16::MAX);
for (index, row) in layout.rows.iter().enumerate() {
let prefix = if index == 0 { "> " } else { " " };
let mut spans = vec![Span::styled(prefix, Style::new().fg(theme.accent))];
spans.extend(styled_input_chunk(&text[row.clone()], row.start, &mentions, theme));
lines.push(Line::from(spans));
}
Position::new(
u16::try_from(layout.cursor_column).unwrap_or(u16::MAX).saturating_add(PREFIX_WIDTH),
first_row.saturating_add(u16::try_from(layout.cursor_row).unwrap_or(u16::MAX)),
)
}
}
fn styled_input_chunk(
chunk: &str,
chunk_start: usize,
mention_ranges: &[std::ops::Range<usize>],
theme: &Theme,
) -> Vec<Span<'static>> {
let in_mention_at = |relative: usize| mention_ranges.iter().any(|range| range.contains(&(chunk_start + relative)));
if mention_ranges.is_empty() || !chunk.contains('@') {
return vec![Span::styled(chunk.to_string(), Style::new().fg(theme.text_primary))];
}
let mut spans = Vec::new();
let mut run_start = 0;
let mut current_info = in_mention_at(0);
for (relative, _) in chunk.char_indices().skip(1) {
let is_info = in_mention_at(relative);
if is_info != current_info {
let color = if current_info { theme.info } else { theme.text_primary };
spans.push(Span::styled(chunk[run_start..relative].to_string(), Style::new().fg(color)));
run_start = relative;
current_info = is_info;
}
}
let color = if current_info { theme.info } else { theme.text_primary };
spans.push(Span::styled(chunk[run_start..].to_string(), Style::new().fg(color)));
spans
}