Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2732,6 +2732,63 @@ mod tests {
assert!(matches!(h.app.view, app::View::Thread(_)));
}

#[test]
fn e2e_confirm_shows_recipient_address() {
let mut h = TuiHarness::new();

h.press_char('n');
h.press_enter(); // select first contact
let recipient = h.app.msg_recipient.clone().expect("recipient set").1;

h.type_text("hi");
h.press_enter();
h.install_confirm_for_pending_send();
assert_eq!(h.app.overlay, Some(Overlay::Confirm));

let screen = h.screen();
let prefix = &recipient[..recipient.len().min(10)];
assert!(
screen.contains(prefix),
"confirm prompt should show the recipient address (looking for {prefix:?})"
);
}

#[test]
fn e2e_confirm_shows_multiline_message() {
let mut h = TuiHarness::new();
h.app.pending_text = Some("first line\nsecond line".to_string());
h.app.overlay = Some(Overlay::Confirm);

let screen = h.screen();
assert!(screen.contains("first line"), "confirm should show line 1");
assert!(screen.contains("second line"), "confirm should show line 2");
}

#[test]
fn e2e_composer_indicates_hidden_lines() {
let mut h = TuiHarness::new();
h.press_char('n');
h.press_enter(); // -> focus Composer in a thread
h.app.input.set("l1\nl2\nl3\nl4\nl5\nl6".to_string());

// cursor sits at the end, so earlier lines are scrolled off above.
let scrolled_down = h.screen();
assert!(
scrolled_down.contains('\u{2191}'),
"composer should show an up arrow when lines are hidden above"
);

// move to the top, so later lines are scrolled off below.
for _ in 0..6 {
h.app.input.move_line_up();
}
let scrolled_up = h.screen();
assert!(
scrolled_up.contains('\u{2193}'),
"composer should show a down arrow when lines are hidden below"
);
}

#[test]
fn e2e_standalone_public_message_flow_submits_to_outbox() {
let mut h = TuiHarness::new();
Expand Down
9 changes: 8 additions & 1 deletion src/ui/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,14 @@ pub fn render_composer(frame: &mut Frame, app: &App, sep: Line<'_>, area: Rect)
avail,
None,
);
let line_prompt = if i == scroll_start && scroll_start == 0 {
// Gutter: the prompt on the first line, or an arrow when there are more
// lines scrolled off above/below (so vertical overflow is visible, like
// the "..." for an over-long single line).
let line_prompt = if i == scroll_start && scroll_start > 0 {
"\u{2191} "
} else if i == scroll_end - 1 && scroll_end < total_lines {
"\u{2193} "
} else if i == scroll_start && scroll_start == 0 {
prompt
} else {
" "
Expand Down
133 changes: 92 additions & 41 deletions src/ui/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,68 @@ fn render_single_input(
}
}

/// Preview lines for the Confirm overlay: the channel being created, or every
/// line of the message being sent (capped). Used both to render the overlay and
/// to size it (`render_main_panel`), so the height matches the content.
pub(super) fn confirm_preview(app: &App, width: u16) -> Vec<Line<'static>> {
let reset = Style::default().fg(ratatui::style::Color::Reset);
let muted = Style::default().fg(palette::MUTED);
let max = usize::from(width).saturating_sub(4);

if let Some(name) = &app.pending_channel_name {
let desc = app.pending_channel_desc.as_deref().unwrap_or("");
let text = if desc.is_empty() {
format!(" #{name}")
} else {
format!(" #{name} -- {desc}")
};
return vec![Line::from(Span::styled(fit(&text, max), reset))];
}

let Some(text) = &app.pending_text else {
return vec![Line::raw("")];
};
if text.is_empty() {
return vec![Line::from(Span::styled(
" empty",
muted.add_modifier(Modifier::ITALIC),
))];
}

let total = text.lines().count();
if total <= 1 {
let first = text.lines().next().unwrap_or("");
return vec![Line::from(Span::styled(
fit(&format!(" \"{first}\" ({} chars)", text.len()), max),
reset,
))];
}

// Multi-line message: show each line (capped), then a dim summary line.
const MAX_LINES: usize = 6;
let mut lines: Vec<Line<'static>> = text
.lines()
.take(MAX_LINES)
.map(|l| {
Line::from(Span::styled(
format!(" {}", fit(l, max.saturating_sub(2))),
reset,
))
})
.collect();
let suffix = if total > MAX_LINES {
format!(
" \u{2026} +{} more lines ({} chars)",
total - MAX_LINES,
text.len()
)
} else {
format!(" ({} chars)", text.len())
};
lines.push(Line::from(Span::styled(suffix, muted)));
lines
}

pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let sep = sep_line(area.width);

Expand Down Expand Up @@ -235,48 +297,11 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
Some(fee) => format!("Fee: {fee}"),
None => format!("{} Estimating fee", app.spinner_1()),
};

let is_channel = app.is_pending_channel();
let action = if is_channel { "Create?" } else { "Send?" };

let (preview, is_empty_preview) = if let Some(name) = &app.pending_channel_name {
let desc = app.pending_channel_desc.as_deref().unwrap_or("");
let text = if desc.is_empty() {
format!(" #{name}")
} else {
format!(" #{name} -- {desc}")
};
let max = usize::from(area.width) - 2;
let trimmed = if text.len() > max {
format!("{}\u{2026}", &text[..max.saturating_sub(1)])
} else {
text
};
(trimmed, false)
} else if let Some(text) = &app.pending_text {
let first = text.lines().next().unwrap_or("");
let max = (usize::from(area.width)).saturating_sub(16);
let display = if first.len() > max {
format!("\"{}\u{2026}\"", &first[..max.saturating_sub(3)])
} else {
format!("\"{first}\"")
};
if text.is_empty() {
(" empty".to_string(), true)
} else {
(format!(" {display} ({} chars)", text.len()), false)
}
} else {
(String::new(), false)
};
let preview_style = if is_empty_preview {
Style::default()
.fg(palette::MUTED)
.add_modifier(Modifier::ITALIC)
let action = if app.is_pending_channel() {
"Create?"
} else {
Style::default().fg(ratatui::style::Color::Reset)
"Send?"
};
let preview_line = Line::from(vec![Span::styled(preview, preview_style)]);

let confirm_line = Line::from(vec![
Span::raw(" "),
Expand All @@ -288,7 +313,33 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
),
Span::styled(fee_text, Style::default().fg(palette::ACCENT)),
]);
frame.render_widget(Paragraph::new(vec![sep, preview_line, confirm_line]), area);

// Show who the message is going to so the recipient can be verified
// before sending. Channels/groups have no single address, so keep the
// plain separator there.
let recipient = if let Some((_, ss58)) = &app.msg_recipient {
Some(ss58.clone())
} else if let crate::app::View::Thread(idx) = app.view {
app.session.threads.get(idx).map(|t| t.peer_ss58.clone())
} else {
None
};
let header = match recipient {
Some(ss58) => Line::from(vec![
Span::raw(" "),
Span::styled("To ", Style::default().fg(palette::MUTED)),
Span::styled(
fit(&ss58, usize::from(area.width).saturating_sub(4)),
Style::default().fg(palette::ACCENT),
),
]),
None => sep,
};

let mut lines = vec![header];
lines.extend(confirm_preview(app, area.width));
lines.push(confirm_line);
frame.render_widget(Paragraph::new(lines), area);
}
Some(Overlay::SenderPicker)
| Some(Overlay::Help)
Expand Down
3 changes: 3 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ fn render_main_panel(frame: &mut Frame, app: &mut App, area: Rect) {

let text_lines = if app.is_composing() && !app.input.is_empty() {
app.input.split('\n').count().clamp(1, 4)
} else if matches!(app.overlay, Some(crate::app::Overlay::Confirm)) {
// Grow to fit the message preview (header + preview + confirm line).
input::confirm_preview(app, inner.width).len()
} else {
1
};
Expand Down