use super::*;
use crate::tui::state::MINIMUM_ESTABLISHED_DM_MESSAGES;
use crate::tui::ui::emoji_overlay::{EmojiSlot, overlay_emoji_column, overlay_emoji_slots};
pub(in crate::tui::ui) fn render_composer(
frame: &mut Frame,
area: Rect,
state: &DashboardState,
emoji_images: &[EmojiImage<'_>],
) {
let inner_width = composer_inner_width(area.width);
let ready_urls = ready_custom_emoji_urls(emoji_images);
let prompt = composer_lines_with_loaded_custom_emoji_urls(state, inner_width, &ready_urls);
let composer_active = state.is_composing() && state.composer_lock().is_none();
let theme = theme::current();
let border_style = if composer_active {
theme.style(theme::HighlightGroup::ActiveComposerBorder)
} else {
theme.style(theme::HighlightGroup::ComposerBorder)
};
frame.render_widget(
Paragraph::new(prompt)
.style(if composer_active {
Style::default()
} else {
theme.style(theme::HighlightGroup::Disabled)
})
.block(
Block::default()
.title(state.composer_title())
.borders(Borders::ALL)
.border_type(theme.border_type(theme::BorderSurface::Composer))
.border_style(border_style)
.title_style(theme.style(theme::HighlightGroup::ComposerTitle)),
),
area,
);
if state.show_custom_emoji() {
render_composer_custom_emoji_images(frame, area, state, emoji_images);
}
render_composer_attachment_previews(frame, area, state);
if let Some(position) =
composer_cursor_position_with_loaded_custom_emoji_urls(area, state, &ready_urls)
{
frame.set_cursor_position(position);
}
}
fn ready_custom_emoji_urls(emoji_images: &[EmojiImage<'_>]) -> Vec<String> {
emoji_images.iter().map(|image| image.url.clone()).collect()
}
#[cfg(test)]
pub(in crate::tui::ui) fn composer_cursor_position(
area: Rect,
state: &DashboardState,
) -> Option<Position> {
composer_cursor_position_with_loaded_custom_emoji_urls(area, state, &[])
}
fn composer_cursor_position_with_loaded_custom_emoji_urls(
area: Rect,
state: &DashboardState,
loaded_custom_emoji_urls: &[String],
) -> Option<Position> {
if !state.is_composing() || state.composer_lock().is_some() || area.width < 3 || area.height < 3
{
return None;
}
let inner_width = composer_inner_width(area.width) as usize;
let cursor = state.composer_cursor_byte_index();
let display_input = composer_display_input(state, loaded_custom_emoji_urls);
let display_cursor = display_input
.map_byte_index(cursor)
.min(display_input.input.len());
let text_before_cursor = &display_input.input[..display_cursor];
let prefixed = prefixed_composer_input(text_before_cursor);
let wrapped = wrap_text_lines(&prefixed, inner_width);
let mut prompt_row = wrapped.len().saturating_sub(1);
let mut prompt_column = wrapped.last().map(|line| line.width()).unwrap_or_default();
if prompt_column >= inner_width {
prompt_row = prompt_row.saturating_add(1);
prompt_column = 0;
}
let mut content_row = composer_rows_before_input(state);
content_row = content_row.saturating_add(prompt_row);
let x = area
.x
.saturating_add(1)
.saturating_add(u16::try_from(prompt_column).unwrap_or(u16::MAX));
let y = area
.y
.saturating_add(1)
.saturating_add(u16::try_from(content_row).unwrap_or(u16::MAX));
let inner_right = area.x.saturating_add(area.width.saturating_sub(1));
let inner_bottom = area.y.saturating_add(area.height.saturating_sub(1));
if x >= inner_right || y >= inner_bottom {
return None;
}
Some(Position { x, y })
}
pub(in crate::tui::ui) fn render_composer_mention_picker(
frame: &mut Frame,
message_areas: MessageAreas,
state: &DashboardState,
) {
if state.composer_mention_query().is_none() {
return;
}
let candidates = state.composer_mention_candidates();
if candidates.is_empty() {
return;
}
let Some(setup) = composer_picker_render_setup(
message_areas,
candidates.len(),
state.composer_mention_selected(),
|visible_count| state.composer_mention_window_start(visible_count, candidates.len()),
) else {
return;
};
clear_area(frame, setup.area);
let visible_candidates = &candidates[setup.visible_range.clone()];
let lines = mention_picker_lines(visible_candidates, setup.selected_offset, setup.inner_width);
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme::current().border_type(theme::BorderSurface::Picker))
.border_style(theme::current().style(theme::HighlightGroup::ComposerPickerBorder))
.title(" mention ")
.title_style(theme::current().style(theme::HighlightGroup::ComposerTitle));
frame.render_widget(Paragraph::new(lines).block(block), setup.area);
render_composer_picker_scrollbar(frame, &setup, candidates.len());
}
pub(in crate::tui::ui) fn render_composer_command_picker(
frame: &mut Frame,
message_areas: MessageAreas,
state: &DashboardState,
) {
if state.composer_command_query().is_none() {
return;
}
let candidates = state.composer_command_candidates();
if candidates.is_empty() {
return;
}
let Some(setup) = composer_picker_render_setup(
message_areas,
candidates.len(),
state.composer_command_selected(),
|visible_count| state.composer_command_window_start(visible_count, candidates.len()),
) else {
return;
};
let lines = command_picker_lines(
&candidates[setup.visible_range.clone()],
setup.selected_offset,
setup.inner_width,
);
clear_area(frame, setup.area);
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.title(" Commands ")
.borders(Borders::ALL)
.border_type(theme::current().border_type(theme::BorderSurface::Picker))
.border_style(theme::current().style(theme::HighlightGroup::ComposerPickerBorder)),
),
setup.area,
);
render_composer_picker_scrollbar(frame, &setup, candidates.len());
}
pub(in crate::tui::ui) fn render_composer_emoji_picker(
frame: &mut Frame,
message_areas: MessageAreas,
state: &DashboardState,
emoji_images: &[EmojiImage<'_>],
) {
if state.composer_emoji_query().is_none() {
return;
}
let candidates = state.composer_emoji_candidates();
if candidates.is_empty() {
return;
}
let Some(setup) = composer_picker_render_setup(
message_areas,
candidates.len(),
state.composer_emoji_selected(),
|visible_count| state.composer_emoji_window_start(visible_count, candidates.len()),
) else {
return;
};
clear_area(frame, setup.area);
let visible_candidates = &candidates[setup.visible_range.clone()];
let ready_urls = emoji_images
.iter()
.map(|image| image.url.clone())
.collect::<Vec<_>>();
let lines = emoji_picker_lines(
visible_candidates,
setup.selected_offset,
setup.inner_width,
&ready_urls,
state.show_custom_emoji(),
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme::current().border_type(theme::BorderSurface::Picker))
.border_style(theme::current().style(theme::HighlightGroup::ComposerPickerBorder))
.title(" emoji ")
.title_style(theme::current().style(theme::HighlightGroup::ComposerTitle));
frame.render_widget(Paragraph::new(lines).block(block), setup.area);
if state.show_custom_emoji() {
render_composer_emoji_picker_images(frame, setup.area, visible_candidates, emoji_images);
}
render_composer_picker_scrollbar(frame, &setup, candidates.len());
}
struct ComposerPickerRenderSetup {
area: Rect,
visible_range: std::ops::Range<usize>,
selected_offset: usize,
inner_width: usize,
}
fn composer_picker_render_setup(
message_areas: MessageAreas,
candidate_count: usize,
selected: usize,
window_start_for_visible_count: impl FnOnce(usize) -> usize,
) -> Option<ComposerPickerRenderSetup> {
let area = composer_picker_area(message_areas, candidate_count)?;
let visible_count = usize::from(area.height.saturating_sub(2))
.min(candidate_count)
.max(1);
let selected = selected.min(candidate_count.saturating_sub(1));
let window_start = window_start_for_visible_count(visible_count);
let visible_end = (window_start + visible_count).min(candidate_count);
let shows_scrollbar = candidate_count > visible_count;
let inner_width = area
.width
.saturating_sub(2)
.saturating_sub(u16::from(shows_scrollbar)) as usize;
Some(ComposerPickerRenderSetup {
area,
visible_range: window_start..visible_end,
selected_offset: selected.saturating_sub(window_start),
inner_width,
})
}
fn render_composer_picker_scrollbar(
frame: &mut Frame,
setup: &ComposerPickerRenderSetup,
candidate_count: usize,
) {
render_vertical_scrollbar(
frame,
panel_scrollbar_area(setup.area),
setup.visible_range.start,
setup.visible_range.len(),
candidate_count,
);
}
pub(in crate::tui::ui) fn active_composer_picker_area(
message_areas: MessageAreas,
state: &DashboardState,
) -> Option<Rect> {
if state.composer_command_query().is_some() {
let candidates = state.composer_command_candidates();
if !candidates.is_empty() {
return composer_picker_area(message_areas, candidates.len());
}
}
if state.composer_mention_query().is_some() {
let candidates = state.composer_mention_candidates();
if !candidates.is_empty() {
return composer_picker_area(message_areas, candidates.len());
}
}
if state.composer_emoji_query().is_some() {
let candidates = state.composer_emoji_candidates();
if !candidates.is_empty() {
return composer_picker_area(message_areas, candidates.len());
}
}
None
}
fn composer_picker_area(message_areas: MessageAreas, candidate_count: usize) -> Option<Rect> {
let composer = message_areas.composer;
let messages = message_areas.list;
if composer.x < messages.x || composer.width == 0 {
return None;
}
let desired_height = (candidate_count.min(MAX_MENTION_PICKER_VISIBLE) as u16).saturating_add(2);
let available_above = composer.y.saturating_sub(messages.y);
let height = desired_height.min(available_above);
if height < 3 {
return None;
}
let width = composer.width.min(messages.width);
let x = composer.x;
let y = composer.y.saturating_sub(height);
Some(Rect {
x,
y,
width,
height,
})
}
fn mention_picker_lines(
candidates: &[MentionPickerEntry],
selected: usize,
width: usize,
) -> Vec<Line<'static>> {
let max_label_width = width.saturating_sub(4).max(1);
candidates
.iter()
.enumerate()
.map(|(index, entry)| {
let cursor = if index == selected { "› " } else { " " };
let username_hint = entry
.username
.as_deref()
.filter(|name| !name.eq_ignore_ascii_case(&entry.display_name))
.map(|name| format!(" @{name}"))
.unwrap_or_default();
let bot_marker = if entry.is_bot { " [BOT]" } else { "" };
let bot_width = bot_marker.width().min(max_label_width);
let label = truncate_display_width(
entry.display_label(),
max_label_width.saturating_sub(bot_width),
);
let username_hint = truncate_display_width(
&username_hint,
max_label_width
.saturating_sub(bot_width)
.saturating_sub(label.width()),
);
let selected = index == selected;
let role_color = match entry.target {
MentionPickerTarget::Everyone(_) | MentionPickerTarget::Role(_) => entry.role_color,
MentionPickerTarget::User(_) | MentionPickerTarget::Channel(_) => None,
};
let row_style = selected_discord_text_style(
selected,
mention_picker_entry_style(entry),
role_color,
);
let marker = match entry.target {
MentionPickerTarget::User(_) => presence_marker(entry.status).to_string(),
MentionPickerTarget::Everyone(_) | MentionPickerTarget::Role(_) => "@".to_owned(),
MentionPickerTarget::Channel(_) => "#".to_owned(),
};
let marker_style = if matches!(entry.target, MentionPickerTarget::User(_)) {
selected_presence_style(selected, entry.status)
} else {
row_style
};
let mut spans = vec![
Span::styled(cursor, row_style),
Span::styled(marker, marker_style),
Span::styled(" ", row_style),
Span::styled(label, row_style),
];
if entry.is_bot && bot_width == bot_marker.width() {
spans.push(Span::raw(bot_marker));
}
spans.push(Span::styled(username_hint, row_style));
selected_row_line(Line::from(spans), selected)
})
.collect()
}
#[cfg(test)]
pub(in crate::tui::ui) fn mention_picker_lines_for_test(
candidates: &[MentionPickerEntry],
selected: usize,
width: usize,
) -> Vec<Line<'static>> {
mention_picker_lines(candidates, selected, width)
}
fn mention_picker_entry_style(entry: &MentionPickerEntry) -> Style {
match entry.target {
MentionPickerTarget::Everyone(_) | MentionPickerTarget::Role(_) => {
apply_discord_foreground(
theme::current().style(theme::HighlightGroup::MentionPickerRole),
entry.role_color,
)
}
MentionPickerTarget::User(_) | MentionPickerTarget::Channel(_) => Style::default(),
}
}
fn command_picker_lines(
candidates: &[CommandPickerEntry],
selected: usize,
inner_width: usize,
) -> Vec<Line<'static>> {
candidates
.iter()
.enumerate()
.map(|(index, entry)| {
let selected = index == selected;
let marker = selection_marker(selected);
let marker_width = marker.content.width();
let label_width = entry.label.width();
let detail_width = inner_width
.saturating_sub(marker_width)
.saturating_sub(label_width)
.saturating_sub(1);
selected_row_line(
Line::from(vec![
marker,
Span::styled(
entry.label.clone(),
selected_text_style(
selected,
theme::current().style(theme::HighlightGroup::Strong),
),
),
Span::raw(" "),
Span::styled(
truncate_display_width(&entry.detail, detail_width),
theme::current().style(theme::HighlightGroup::Description),
),
]),
selected,
)
})
.collect()
}
pub(in crate::tui::ui) fn emoji_picker_lines(
candidates: &[EmojiPickerEntry],
selected: usize,
width: usize,
ready_custom_emoji_urls: &[String],
show_custom_emoji: bool,
) -> Vec<Line<'static>> {
candidates
.iter()
.enumerate()
.map(|(index, entry)| {
let cursor = selection_marker_with("› ", index == selected);
let custom_image_ready = show_custom_emoji
&& entry
.custom_image_url
.as_ref()
.is_some_and(|url| ready_custom_emoji_urls.iter().any(|ready| ready == url));
let prefix_width = emoji_picker_entry_prefix_width(entry, custom_image_ready);
let description = entry.available_as_link.then_some("available as image link");
let description_width = description
.map(|value| value.width().saturating_add(" - ".width()))
.unwrap_or_default();
let max_label_width = width
.saturating_sub(2)
.saturating_sub(prefix_width)
.saturating_sub(description_width)
.max(1);
let label = format!(":{}: {}", entry.shortcode, entry.name);
let label = truncate_display_width(&label, max_label_width);
let mut row_style = if entry.available {
Style::default()
} else {
theme::current().style(theme::HighlightGroup::UnavailableEmoji)
};
if index == selected {
row_style = theme::current().apply(theme::HighlightGroup::SelectedRow, row_style);
}
let mut spans = vec![cursor];
spans.extend(emoji_picker_entry_prefix(
entry,
custom_image_ready,
row_style,
));
spans.push(Span::styled(label, row_style));
if let Some(description) = description {
spans.push(Span::styled(
" - ",
theme::current().style(theme::HighlightGroup::Description),
));
spans.push(Span::styled(
description,
theme::current().style(theme::HighlightGroup::Description),
));
}
selected_row_line(Line::from(spans), index == selected)
})
.collect()
}
fn emoji_picker_entry_prefix_width(entry: &EmojiPickerEntry, custom_image_ready: bool) -> usize {
if entry.custom_image_url.is_some() {
usize::from(custom_image_ready) * usize::from(EMOJI_REACTION_IMAGE_WIDTH.saturating_add(1))
} else {
entry.emoji.as_str().width().saturating_add(1)
}
}
fn emoji_picker_entry_prefix(
entry: &EmojiPickerEntry,
custom_image_ready: bool,
row_style: Style,
) -> Vec<Span<'static>> {
if entry.custom_image_url.is_some() {
if custom_image_ready {
vec![Span::styled(
" ".repeat(usize::from(EMOJI_REACTION_IMAGE_WIDTH.saturating_add(1))),
row_style,
)]
} else {
Vec::new()
}
} else {
vec![
Span::styled(entry.emoji.clone(), row_style),
Span::styled(" ", row_style),
]
}
}
fn render_composer_emoji_picker_images(
frame: &mut Frame,
area: Rect,
candidates: &[EmojiPickerEntry],
emoji_images: &[EmojiImage<'_>],
) {
let content = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
overlay_emoji_column(
frame,
content,
2,
candidates
.iter()
.map(|entry| entry.custom_image_url.as_deref()),
emoji_images,
);
}
#[cfg(test)]
pub(in crate::tui::ui) fn composer_lines(state: &DashboardState, width: u16) -> Vec<Line<'static>> {
composer_lines_with_loaded_custom_emoji_urls(state, width, &[])
}
pub(in crate::tui::ui) fn composer_lines_with_loaded_custom_emoji_urls(
state: &DashboardState,
width: u16,
loaded_custom_emoji_urls: &[String],
) -> Vec<Line<'static>> {
if state.composer_lock().is_none()
&& (state.is_composing()
|| !state.composer_input().is_empty()
|| !state.pending_composer_attachments().is_empty()
|| state.clipboard_paste_pending())
{
let mut lines = pending_upload_lines(state, width);
append_composer_upload_preview_lines(&mut lines, state, width);
let display_input = composer_display_input(state, loaded_custom_emoji_urls);
if state.is_composing()
&& let Some(message) = state.reply_target_message_state()
{
let (ping_label, ping_style) = reply_ping_indicator(state);
lines.push(Line::from(vec![
Span::styled(
reply_target_hint(message, state, width),
theme::current().style(theme::HighlightGroup::MessageSecondary),
),
Span::raw(REPLY_PING_SEPARATOR),
Span::styled(ping_label, ping_style),
]));
}
let prefixed_input = prefixed_composer_input(&display_input.input);
let wrapped = wrap_text_lines(&prefixed_input, width as usize);
for subline in wrapped {
lines.push(Line::from(subline));
}
return lines;
}
let text = composer_text(state, width);
let wrapped = wrap_text_lines(&text, width as usize);
if state
.composer_lock()
.is_some_and(|lock| lock != ComposerLock::LoadingMessages)
{
return wrapped
.into_iter()
.map(|subline| {
Line::from(Span::styled(
subline,
theme::current().style(theme::HighlightGroup::Error),
))
})
.collect();
}
wrapped.into_iter().map(Line::from).collect()
}
struct ComposerDisplayInput {
input: String,
replacements: Vec<ComposerEmojiReplacement>,
}
struct ComposerEmojiReplacement {
start: usize,
end: usize,
new_start: usize,
new_len: usize,
}
impl ComposerDisplayInput {
fn map_byte_index(&self, position: usize) -> usize {
let mut delta = 0isize;
for replacement in &self.replacements {
if position < replacement.start {
break;
}
if position < replacement.end {
let inside = position.saturating_sub(replacement.start);
return replacement
.new_start
.saturating_add(inside.min(replacement.new_len));
}
delta += replacement.new_len as isize - (replacement.end - replacement.start) as isize;
}
if delta < 0 {
position.saturating_sub(delta.unsigned_abs())
} else {
position.saturating_add(delta as usize)
}
}
}
fn composer_display_input(
state: &DashboardState,
loaded_custom_emoji_urls: &[String],
) -> ComposerDisplayInput {
let original = state.composer_input();
let mut completions = state.composer_emoji_image_completions();
completions.sort_by_key(|completion| completion.byte_start);
if completions.is_empty() || loaded_custom_emoji_urls.is_empty() {
return ComposerDisplayInput {
input: original.to_owned(),
replacements: Vec::new(),
};
}
let mut input = String::with_capacity(original.len());
let mut cursor = 0usize;
let mut replacements = Vec::new();
for completion in completions {
if completion.byte_end > original.len()
|| !original.is_char_boundary(completion.byte_start)
|| !original.is_char_boundary(completion.byte_end)
{
continue;
}
let start = completion.byte_start;
let end = completion.byte_end;
if start < cursor {
continue;
}
input.push_str(&original[cursor..start]);
let new_start = input.len();
if loaded_custom_emoji_urls
.iter()
.any(|url| url == &completion.url)
{
let placeholder = " ".repeat(usize::from(EMOJI_REACTION_IMAGE_WIDTH));
input.push_str(&placeholder);
replacements.push(ComposerEmojiReplacement {
start,
end,
new_start,
new_len: placeholder.len(),
});
} else {
input.push_str(&original[start..end]);
}
cursor = end;
}
input.push_str(&original[cursor..]);
ComposerDisplayInput {
input,
replacements,
}
}
fn render_composer_custom_emoji_images(
frame: &mut Frame,
area: Rect,
state: &DashboardState,
emoji_images: &[EmojiImage<'_>],
) {
if !state.is_composing() || area.width < 3 || area.height < 3 {
return;
}
let ready_urls = ready_custom_emoji_urls(emoji_images);
let display_input = composer_display_input(state, &ready_urls);
let input = display_input.input.as_str();
let inner_width = composer_inner_width(area.width) as usize;
let content_row = composer_rows_before_input(state);
let mut slots = Vec::new();
for completion in state.composer_emoji_image_completions() {
let Some((row, column)) = composer_custom_emoji_image_position(
input,
display_input.map_byte_index(completion.byte_start),
display_input.map_byte_index(completion.byte_end),
inner_width,
) else {
continue;
};
slots.push(EmojiSlot {
row_in_list: 1isize.saturating_add(content_row.saturating_add(row) as isize),
col: area.x as isize + 1 + column as isize,
max_width: u16::MAX,
url: completion.url,
});
}
let list = Rect {
width: area.width.saturating_sub(1),
height: area.height.saturating_sub(1),
..area
};
overlay_emoji_slots(frame, list, emoji_images, &[], slots.into_iter());
}
fn composer_custom_emoji_image_position(
input: &str,
byte_start: usize,
byte_end: usize,
inner_width: usize,
) -> Option<(usize, usize)> {
if inner_width == 0 || byte_start > byte_end || byte_end > input.len() {
return None;
}
let before = prefixed_composer_input(&input[..byte_start]);
let through = prefixed_composer_input(&input[..byte_end]);
let before_wrapped = wrap_text_lines(&before, inner_width);
let through_wrapped = wrap_text_lines(&through, inner_width);
if before_wrapped.len() != through_wrapped.len() {
return None;
}
Some((
before_wrapped.len().saturating_sub(1),
before_wrapped
.last()
.map(|line| line.width())
.unwrap_or_default(),
))
}
fn render_composer_attachment_previews(frame: &mut Frame, area: Rect, state: &DashboardState) {
let previews = state.composer_attachment_previews();
if previews.is_empty() || composer_upload_preview_line_count(state) == 0 {
return;
}
let Some(preview_row) = composer_upload_preview_start_row(state) else {
return;
};
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
if inner.width == 0 || inner.height == 0 {
return;
}
let y = inner.y.saturating_add(preview_row);
if y >= inner.y.saturating_add(inner.height) {
return;
}
let height = LOCAL_UPLOAD_PREVIEW_HEIGHT.min(inner.y.saturating_add(inner.height) - y);
if height == 0 {
return;
}
let tile_width = LOCAL_UPLOAD_PREVIEW_WIDTH.min(inner.width);
if tile_width == 0 {
return;
}
for (index, preview) in previews.into_iter().enumerate() {
let x_offset = u16::try_from(index)
.unwrap_or(u16::MAX)
.saturating_mul(tile_width.saturating_add(1));
let x = inner.x.saturating_add(x_offset);
if x >= inner.x.saturating_add(inner.width) {
break;
}
let preview_area = Rect {
x,
y,
width: tile_width.min(inner.x.saturating_add(inner.width) - x),
height,
};
render_composer_attachment_preview(frame, preview_area, preview);
}
}
fn composer_upload_preview_start_row(state: &DashboardState) -> Option<u16> {
(composer_upload_preview_line_count(state) > 0).then(|| {
u16::try_from(state.pending_composer_upload_line_count())
.unwrap_or(u16::MAX)
.saturating_add(1)
})
}
fn render_composer_attachment_preview(
frame: &mut Frame,
area: Rect,
preview: LocalUploadPreviewView<'_>,
) {
match preview {
LocalUploadPreviewView::Loading { filename } => frame.render_widget(
Paragraph::new(format!("loading {filename}..."))
.style(theme::current().style(theme::HighlightGroup::Loading))
.wrap(Wrap { trim: false }),
area,
),
LocalUploadPreviewView::Failed { filename, message } => frame.render_widget(
Paragraph::new(format!("{filename}: {message}"))
.style(theme::current().style(theme::HighlightGroup::Warning))
.wrap(Wrap { trim: false }),
area,
),
LocalUploadPreviewView::Ready { protocol } => {
frame.render_widget(RatatuiImage::new(protocol), area);
}
}
}
fn pending_upload_lines(state: &DashboardState, width: u16) -> Vec<Line<'static>> {
pending_upload_texts(state, width)
.into_iter()
.map(|label| {
Line::from(Span::styled(
label,
theme::current().style(theme::HighlightGroup::Warning),
))
})
.collect()
}
fn pending_upload_texts(state: &DashboardState, width: u16) -> Vec<String> {
let max_width = usize::from(width).max(1);
let mut lines: Vec<_> = state
.pending_composer_attachments()
.iter()
.map(|attachment| {
let label = format!(
"upload: {} ({})",
attachment.filename,
format_byte_size(attachment.size_bytes)
);
truncate_display_width(&label, max_width)
})
.collect();
if state.clipboard_paste_pending() {
lines.push(truncate_display_width(
"upload: ⠋ processing clipboard attachment...",
max_width,
));
}
lines
}
fn append_composer_upload_preview_lines(
lines: &mut Vec<Line<'static>>,
state: &DashboardState,
width: u16,
) {
append_composer_upload_preview_rows(lines, state, width, |text| {
Line::from(Span::styled(
text,
theme::current().style(theme::HighlightGroup::Description),
))
});
}
fn append_composer_upload_preview_texts(
lines: &mut Vec<String>,
state: &DashboardState,
width: u16,
) {
append_composer_upload_preview_rows(lines, state, width, |text| text);
}
fn append_composer_upload_preview_rows<T>(
lines: &mut Vec<T>,
state: &DashboardState,
width: u16,
build_line: impl Fn(String) -> T,
) {
if composer_upload_preview_line_count(state) == 0 {
return;
}
let max_width = usize::from(width).max(1);
let separator = truncate_display_width(&"─".repeat(max_width), max_width);
lines.push(build_line(separator.clone()));
for _ in 0..LOCAL_UPLOAD_PREVIEW_HEIGHT {
lines.push(build_line(String::new()));
}
lines.push(build_line(separator));
}
pub(in crate::tui::ui) fn composer_text(state: &DashboardState, width: u16) -> String {
if state.composer_lock().is_none() && state.is_composing() {
let mut lines = pending_upload_texts(state, width);
append_composer_upload_preview_texts(&mut lines, state, width);
let input = prefixed_composer_input(state.composer_input());
if let Some(message) = state.reply_target_message_state() {
let (ping_label, _) = reply_ping_indicator(state);
lines.push(format!(
"{}{REPLY_PING_SEPARATOR}{ping_label}",
reply_target_hint(message, state, width)
));
}
lines.push(input);
return lines.join("\n");
}
if state.composer_lock().is_none()
&& (!state.composer_input().is_empty()
|| !state.pending_composer_attachments().is_empty()
|| state.clipboard_paste_pending())
{
let mut lines = pending_upload_texts(state, width);
append_composer_upload_preview_texts(&mut lines, state, width);
lines.push(prefixed_composer_input(state.composer_input()));
return lines.join("\n");
}
if let Some(channel) = state.selected_channel_state() {
let label = match channel.kind.as_str() {
"dm" | "Private" => format!("@{}", channel.name),
"group-dm" | "Group" => channel.name.clone(),
_ => format!("#{}", channel.name),
};
if channel.is_forum() {
if state.can_create_post_in_selected_channel() {
return format!(
"press {} to create a post in {label}",
state.key_bindings().start_composer_key_label()
);
}
return format!("read-only · cannot create posts in {label}");
}
if let Some(lock) = state.composer_lock() {
return match lock {
ComposerLock::LoadingMessages => {
format!("loading messages in {label}...")
}
ComposerLock::MessageLoadFailed => {
format!("read-only · could not load messages in {label}. reopen it to retry")
}
ComposerLock::Spam => {
format!(
"read-only · {label} is flagged as spam. open it in the official app first"
)
}
ComposerLock::MessageRequest => {
format!(
"read-only · {label} is a message request. accept it in the official app first"
)
}
ComposerLock::NewConversation => {
format!(
"read-only · {label} is a new conversation. send at least {MINIMUM_ESTABLISHED_DM_MESSAGES} messages in the official app first"
)
}
ComposerLock::EmptyChannel => {
format!(
"read-only · {label} has no messages. start it in the official app first"
)
}
ComposerLock::SlowMode { remaining_seconds } => {
format!("slowmode · wait {remaining_seconds}s before writing in {label}")
}
ComposerLock::Verification(restriction) => {
verification_composer_text(&label, restriction)
}
};
}
if !state.can_send_in_selected_channel() {
return format!("read-only · cannot send messages in {label}");
}
if !state.can_attach_in_selected_channel() {
return format!(
"press {} to write in {label} (attachments disabled)",
state.key_bindings().start_composer_key_label()
);
}
return format!(
"press {} to write in {label}",
state.key_bindings().start_composer_key_label()
);
}
"select a channel to write a message".to_owned()
}
pub(in crate::tui::ui) fn verification_composer_text(
label: &str,
block: GuildParticipationBlock,
) -> String {
let GuildParticipationBlock::Restricted(restriction) = block else {
return format!("read-only · Discord verification status is not available for {label}");
};
match restriction {
GuildParticipationRestriction::MembershipScreening => {
format!("read-only · complete {label}'s membership screening in the official app")
}
GuildParticipationRestriction::OnboardingIncomplete => {
format!("read-only · complete {label}'s server onboarding in the official app")
}
GuildParticipationRestriction::EmailVerificationRequired => {
format!("read-only · verify your Discord account email before writing in {label}")
}
GuildParticipationRestriction::AccountTooNew { remaining_seconds } => format!(
"read-only · account verification wait: {remaining_seconds}s remaining for {label}"
),
GuildParticipationRestriction::MemberTooNew { remaining_seconds } => format!(
"read-only · server verification wait: {remaining_seconds}s remaining for {label}"
),
GuildParticipationRestriction::PhoneVerificationRequired => {
format!("read-only · verify your Discord account phone before writing in {label}")
}
GuildParticipationRestriction::UnsupportedLevel { value } => {
format!("read-only · {label} uses unsupported verification level {value}")
}
}
}
const REPLY_PING_SEPARATOR: &str = " ";
fn reply_target_hint(message: &MessageState, state: &DashboardState, width: u16) -> String {
const PREFIX: &str = "reply to ";
let (ping_label, _) = reply_ping_indicator(state);
let reserved = PREFIX.width() + REPLY_PING_SEPARATOR.width() + ping_label.width();
let excerpt_width = usize::from(width).saturating_sub(reserved).max(1);
format!(
"{PREFIX}{}",
truncate_display_width(&reply_target_excerpt(message, state), excerpt_width)
)
}
fn reply_ping_indicator(state: &DashboardState) -> (&'static str, Style) {
if state.ping_on_reply() {
(
"@ on",
theme::current().style(theme::HighlightGroup::ReplyPingEnabled),
)
} else {
(
"@ off",
theme::current().style(theme::HighlightGroup::Disabled),
)
}
}
fn reply_target_excerpt(message: &MessageState, state: &DashboardState) -> String {
if let Some(content) = message
.content
.as_deref()
.filter(|value| !value.trim().is_empty())
{
let rendered = state.render_user_mentions(message.guild_id, &message.mentions, content);
return rendered.split_whitespace().collect::<Vec<_>>().join(" ");
}
if !message.attachments.is_empty() {
return format_attachment_summary(&message.attachments);
}
if message.content.is_some() {
"<empty message>".to_owned()
} else {
"<message content unavailable>".to_owned()
}
}