Skip to main content

wisp/surfaces/composer/
view.rs

1use 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
14/// Columns the `> ` / `  ` line prefix occupies.
15const PREFIX_WIDTH: u16 = 2;
16
17/// The composer's input as it is drawn: one byte range per visual row, and
18/// where the cursor sits among them.
19///
20/// Vertical cursor movement and rendering both read this, so a keystroke can
21/// never disagree with the rows the user is looking at.
22pub(super) struct InputLayout {
23    pub rows: Vec<Range<usize>>,
24    pub cursor_row: usize,
25    pub cursor_column: usize,
26}
27
28/// Lays `text` out over `content_width` columns.
29///
30/// A cursor at the end of a completely full row has no column left to occupy
31/// there, so it moves to the start of the row below — one that is added empty
32/// when the wrap does not already continue onto it.
33pub(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    /// The byte offset `column` maps to on `row`, clamped to the row's end when
56    /// the row is too short to reach it.
57    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
63/// The composer body as a clipped, one-frame widget. Its cursor calculation
64/// uses the same visible range as its painting, so terminal placement cannot
65/// drift from wrapped input rows.
66pub(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    /// Adopts a new terminal width, so wrapping and vertical cursor movement
95    /// follow the rows the next layout will produce.
96    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    /// Lays the composer out for `width` columns: a rule, the wrapped input with
101    /// its `@mention`s highlighted, any attachments, and a closing rule.
102    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    /// Appends the wrapped input rows, returning where the cursor landed.
122    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
148/// Split a wrapped input chunk into styled spans, colouring any `@mention` bytes that overlap
149/// the chunk (identified by their absolute byte offset in the full composer text) in the info
150/// colour and everything else in the primary text colour.
151fn 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}