wisp/surfaces/composer/
view.rs1use super::{Composer, ComposerLayout};
2use crate::attachment::{AttachmentKind, classify_attachment};
3use crate::theme::Theme;
4use crate::view::widgets::RowsView;
5use crate::view::wrap::{fit_prefix, wrap_text_char};
6use ratatui::buffer::Buffer;
7use ratatui::layout::{Position, Rect};
8use ratatui::style::Style;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::Widget;
11use std::ops::Range;
12use unicode_width::UnicodeWidthStr;
13
14const PREFIX_WIDTH: u16 = 2;
16
17pub(super) struct InputLayout {
23 pub rows: Vec<Range<usize>>,
24 pub cursor_row: usize,
25 pub cursor_column: usize,
26}
27
28pub(super) fn input_layout(text: &str, cursor_byte: usize, content_width: usize) -> InputLayout {
34 let mut rows = Vec::new();
35 let mut offset = 0;
36 for line in text.split('\n') {
37 for chunk in wrap_text_char(line, content_width) {
38 rows.push(offset..offset + chunk.len());
39 offset += chunk.len();
40 }
41 offset += 1;
42 }
43 let index = rows.iter().position(|row| cursor_byte <= row.end).unwrap_or(rows.len().saturating_sub(1));
44 let column = text[rows[index].start..cursor_byte].width();
45 if column < content_width {
46 return InputLayout { rows, cursor_row: index, cursor_column: column };
47 }
48 if rows.get(index + 1).is_none_or(|next| next.start != rows[index].end) {
49 rows.insert(index + 1, cursor_byte..cursor_byte);
50 }
51 InputLayout { rows, cursor_row: index + 1, cursor_column: 0 }
52}
53
54impl InputLayout {
55 pub(super) fn byte_at(&self, text: &str, row: usize, column: usize) -> Option<usize> {
58 let row = self.rows.get(row)?;
59 Some(row.start + fit_prefix(&text[row.clone()], column).0)
60 }
61}
62
63pub(crate) struct ComposerBodyView<'a> {
67 layout: &'a ComposerLayout,
68 first_row: usize,
69}
70
71impl<'a> ComposerBodyView<'a> {
72 pub(crate) fn new(layout: &'a ComposerLayout, first_row: usize) -> Self {
73 Self { layout, first_row }
74 }
75
76 pub(crate) fn cursor_position(&self, area: Rect) -> Option<Position> {
77 let cursor = self.layout.cursor;
78 if usize::from(cursor.y) < self.first_row {
79 return None;
80 }
81 let x = area.x.saturating_add(cursor.x);
82 let y = area.y.saturating_add(u16::try_from(usize::from(cursor.y) - self.first_row).unwrap_or(u16::MAX));
83 (x < area.right() && y < area.bottom()).then_some(Position::new(x, y))
84 }
85}
86
87impl Widget for ComposerBodyView<'_> {
88 fn render(self, area: Rect, buf: &mut Buffer) {
89 RowsView::new(&self.layout.lines[self.first_row.min(self.layout.lines.len())..]).render(area, buf);
90 }
91}
92
93impl Composer {
94 pub fn on_resize(&mut self, width: u16) {
97 self.content_width = Some(usize::from(width.saturating_sub(PREFIX_WIDTH).max(1)));
98 }
99
100 pub fn layout(&self, width: u16, theme: &Theme) -> ComposerLayout {
103 let content_width = usize::from(width.saturating_sub(PREFIX_WIDTH).max(1));
104 let rule = Line::styled("─".repeat(usize::from(width)), Style::new().fg(theme.muted));
105
106 let mut lines = vec![rule.clone()];
107 let cursor = self.push_input_lines(&mut lines, content_width, theme);
108 lines.extend(self.pending_media().iter().map(|attachment| {
109 let label = match classify_attachment(&attachment.path) {
110 AttachmentKind::Image => "image",
111 AttachmentKind::Audio => "audio",
112 _ => "file",
113 };
114 Line::styled(format!(" attached {label}: {}", attachment.display_name), Style::new().fg(theme.info))
115 }));
116 lines.push(rule);
117
118 ComposerLayout { lines, cursor }
119 }
120
121 fn push_input_lines(&self, lines: &mut Vec<Line<'static>>, content_width: usize, theme: &Theme) -> Position {
123 let text = self.text();
124 let mentions: Vec<Range<usize>> = text
125 .match_indices('@')
126 .filter_map(|(at_pos, _)| {
127 let end = text[at_pos..].find(char::is_whitespace).map_or(text.len(), |offset| at_pos + offset);
128 (end > at_pos).then_some(at_pos..end)
129 })
130 .collect();
131 let layout = input_layout(text, self.buffer.cursor(), content_width);
132 let first_row = u16::try_from(lines.len()).unwrap_or(u16::MAX);
133
134 for (index, row) in layout.rows.iter().enumerate() {
135 let prefix = if index == 0 { "> " } else { " " };
136 let mut spans = vec![Span::styled(prefix, Style::new().fg(theme.accent))];
137 spans.extend(styled_input_chunk(&text[row.clone()], row.start, &mentions, theme));
138 lines.push(Line::from(spans));
139 }
140
141 Position::new(
142 u16::try_from(layout.cursor_column).unwrap_or(u16::MAX).saturating_add(PREFIX_WIDTH),
143 first_row.saturating_add(u16::try_from(layout.cursor_row).unwrap_or(u16::MAX)),
144 )
145 }
146}
147
148fn styled_input_chunk(
152 chunk: &str,
153 chunk_start: usize,
154 mention_ranges: &[std::ops::Range<usize>],
155 theme: &Theme,
156) -> Vec<Span<'static>> {
157 let in_mention_at = |relative: usize| mention_ranges.iter().any(|range| range.contains(&(chunk_start + relative)));
158
159 if mention_ranges.is_empty() || !chunk.contains('@') {
160 return vec![Span::styled(chunk.to_string(), Style::new().fg(theme.text_primary))];
161 }
162
163 let mut spans = Vec::new();
164 let mut run_start = 0;
165 let mut current_info = in_mention_at(0);
166 for (relative, _) in chunk.char_indices().skip(1) {
167 let is_info = in_mention_at(relative);
168 if is_info != current_info {
169 let color = if current_info { theme.info } else { theme.text_primary };
170 spans.push(Span::styled(chunk[run_start..relative].to_string(), Style::new().fg(color)));
171 run_start = relative;
172 current_info = is_info;
173 }
174 }
175 let color = if current_info { theme.info } else { theme.text_primary };
176 spans.push(Span::styled(chunk[run_start..].to_string(), Style::new().fg(color)));
177 spans
178}