use super::*;
use crate::tui::selection;
use crate::tui::state::{
ForumPostComposerField, ForumPostComposerTagView, ForumPostComposerView, LocalUploadPreviewView,
};
use crate::tui::ui::{LOCAL_UPLOAD_PREVIEW_HEIGHT, LOCAL_UPLOAD_PREVIEW_WIDTH};
const FORUM_POST_POPUP_WIDTH: u16 = 78;
const FORUM_POST_POPUP_HEIGHT: u16 = 24;
const TAG_SUMMARY_MIN_VISIBLE: usize = 3;
const TAG_PICKER_WIDTH: u16 = 46;
const TAG_PICKER_VISIBLE_ITEMS: usize = 10;
struct ComposerLayout {
lines: Vec<Line<'static>>,
title_row: usize,
body_row: usize,
body_content_row: usize,
attachments_row: usize,
tags_row: usize,
submit_row: usize,
cancel_row: usize,
preview_row: Option<usize>,
cursor: Option<(usize, usize)>,
}
pub(in crate::tui::ui) fn render_forum_post_composer(
frame: &mut Frame,
area: Rect,
state: &DashboardState,
) {
if !state.is_active_modal_popup(ActiveModalPopupKind::ForumPostComposer) {
return;
}
let Some(view) = state.forum_post_composer_view() else {
return;
};
let previews = state.forum_post_attachment_previews();
let popup = forum_post_composer_popup_area(area);
let inner = render_modal_frame(frame, popup, "Create Forum Post");
let content_width = usize::from(inner.width.saturating_sub(1)).max(1);
let layout = build_composer_layout(&view, content_width, previews.len());
let total = layout.lines.len();
let viewport = inner.height as usize;
let scroll = state
.forum_post_composer_scroll()
.min(total.saturating_sub(viewport));
let visible: Vec<Line<'static>> = layout
.lines
.iter()
.skip(scroll)
.take(viewport)
.cloned()
.collect();
frame.render_widget(Paragraph::new(visible), inner);
render_vertical_scrollbar(frame, inner, scroll, viewport, total);
if let Some(preview_row) = layout.preview_row
&& !previews.is_empty()
&& preview_row >= scroll
{
let row_in_view = preview_row - scroll;
if row_in_view < viewport {
render_forum_post_attachment_previews(frame, inner, row_in_view as u16, previews);
}
}
if let Some((row, column)) = layout.cursor
&& row >= scroll
&& row - scroll < viewport
{
let x = inner
.x
.saturating_add(column as u16)
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
let y = inner.y.saturating_add((row - scroll) as u16);
frame.set_cursor_position(Position::new(x, y));
}
}
pub(in crate::tui::ui) fn forum_post_composer_popup_area(area: Rect) -> Rect {
centered_rect(
area,
FORUM_POST_POPUP_WIDTH
.min(area.width.saturating_sub(2))
.max(12),
FORUM_POST_POPUP_HEIGHT
.min(area.height.saturating_sub(2))
.max(10),
)
}
fn build_composer_layout(
view: &ForumPostComposerView,
width: usize,
preview_count: usize,
) -> ComposerLayout {
let editing_title = view.editing_field == Some(ForumPostComposerField::Title);
let editing_body = view.editing_field == Some(ForumPostComposerField::Body);
let mut lines = Vec::new();
let title_row = lines.len();
lines.push(field_line(
"title",
&view.title,
view.active_field == ForumPostComposerField::Title,
editing_title,
width,
"(empty)",
));
let body_row = lines.len();
lines.push(section_line(
"body:",
view.active_field == ForumPostComposerField::Body,
editing_body,
));
let body_content_row = lines.len();
let body_lines = visible_body_lines(&view.body);
let body_active = view.active_field == ForumPostComposerField::Body;
for line in &body_lines {
lines.push(Line::from(Span::styled(
truncate_display_width(&format!(" {line}"), width),
editable_field_value_style(body_active, editing_body),
)));
}
if body_lines.is_empty() {
lines.push(Line::from(Span::styled(
" (empty)",
theme::current().style(theme::HighlightGroup::Placeholder),
)));
}
let attachments_row = lines.len();
lines.push(section_line(
"attachments:",
view.active_field == ForumPostComposerField::Attachments,
false,
));
if view.attachments.is_empty() && !view.paste_pending {
lines.push(Line::from(Span::styled(
" (empty)",
theme::current().style(theme::HighlightGroup::Placeholder),
)));
} else {
for attachment in &view.attachments {
lines.push(Line::from(Span::styled(
truncate_display_width(
&format!(
" upload: {} ({})",
attachment.filename,
format_byte_size(attachment.size_bytes)
),
width,
),
theme::current().style(theme::HighlightGroup::Warning),
)));
}
if view.paste_pending {
lines.push(Line::from(Span::styled(
truncate_display_width(" upload: processing clipboard attachment...", width),
theme::current().style(theme::HighlightGroup::Loading),
)));
}
}
let preview_row = (preview_count > 0).then(|| {
let row = lines.len();
for _ in 0..LOCAL_UPLOAD_PREVIEW_HEIGHT {
lines.push(Line::from(""));
}
row
});
let tags_row = lines.len();
lines.push(editable_tags_section_line(
view.active_field == ForumPostComposerField::Tags,
view.requires_tag,
));
push_tag_summary(&mut lines, &view.tags, width);
lines.push(Line::from(""));
let submit_row = lines.len();
lines.push(popup_button_line(
"s",
"submit",
view.active_field == ForumPostComposerField::Submit,
));
let cancel_row = lines.len();
lines.push(popup_button_line(
"c",
"cancel",
view.active_field == ForumPostComposerField::Cancel,
));
if let Some(status) = view.status.as_deref() {
push_wrapped_styled_popup_text(
&mut lines,
status,
width,
theme::current().style(theme::HighlightGroup::Error),
);
}
let cursor = if editing_title {
Some((
title_row,
"› title: ".width() + cursor_column(&view.title, view.title_cursor),
))
} else if editing_body {
let (line, column) = body_cursor_line_column(&view.body, view.body_cursor);
Some((body_content_row + line, 2 + column))
} else {
None
};
ComposerLayout {
lines,
title_row,
body_row,
body_content_row,
attachments_row,
tags_row,
submit_row,
cancel_row,
preview_row,
cursor,
}
}
fn focus_rows(view: &ForumPostComposerView, layout: &ComposerLayout) -> (usize, usize) {
match view.active_field {
ForumPostComposerField::Title => (layout.title_row, layout.body_row),
ForumPostComposerField::Body => {
if view.editing_field == Some(ForumPostComposerField::Body) {
let row = layout
.cursor
.map(|(row, _)| row)
.unwrap_or(layout.body_content_row);
(row, row + 1)
} else {
(layout.body_row, layout.attachments_row)
}
}
ForumPostComposerField::Attachments => (layout.attachments_row, layout.tags_row),
ForumPostComposerField::Tags => (layout.tags_row, layout.submit_row),
ForumPostComposerField::Submit => (layout.submit_row, layout.lines.len()),
ForumPostComposerField::Cancel => (layout.cancel_row, layout.lines.len()),
}
}
fn reveal_target(view: &ForumPostComposerView, layout: &ComposerLayout) -> (usize, usize) {
if let Some((row, _)) = layout.cursor {
(row, row + 1)
} else {
focus_rows(view, layout)
}
}
pub(in crate::tui::ui) struct ForumPostComposerMetrics {
pub total_lines: usize,
pub reveal_start: usize,
pub reveal_end: usize,
}
pub(in crate::tui::ui) fn forum_post_composer_metrics(
view: &ForumPostComposerView,
content_width: usize,
preview_count: usize,
) -> ForumPostComposerMetrics {
let layout = build_composer_layout(view, content_width, preview_count);
let (reveal_start, reveal_end) = reveal_target(view, &layout);
ForumPostComposerMetrics {
total_lines: layout.lines.len(),
reveal_start,
reveal_end,
}
}
fn tag_line(tag: &ForumPostComposerTagView, width: usize, thumbnail_ready: bool) -> Line<'static> {
let marker = if tag.active { "▸" } else { " " };
let checkbox = if tag.selected { "[x]" } else { "[ ]" };
let emoji = super::thread_edit::tag_emoji_text(
tag.unicode_emoji.as_deref(),
tag.custom_emoji_url.as_deref(),
tag.custom_emoji_label.as_deref(),
thumbnail_ready,
);
let style = if tag.active {
highlight_style()
} else if !tag.selectable {
theme::current().style(theme::HighlightGroup::Disabled)
} else {
Style::default()
};
Line::from(Span::styled(
truncate_display_width(&format!("{marker} {checkbox}{emoji} {}", tag.name), width),
style,
))
}
fn push_tag_summary(
lines: &mut Vec<Line<'static>>,
tags: &[ForumPostComposerTagView],
width: usize,
) {
if tags.is_empty() {
lines.push(Line::from(Span::styled(
" no tags available",
theme::current().style(theme::HighlightGroup::Placeholder),
)));
return;
}
let selected_count = tags.iter().filter(|tag| tag.selected).count();
let shown = selected_count.max(TAG_SUMMARY_MIN_VISIBLE).min(tags.len());
for tag in tags.iter().take(shown) {
let checkbox = if tag.selected { "[x]" } else { "[ ]" };
let emoji = super::thread_edit::tag_emoji_text(
tag.unicode_emoji.as_deref(),
tag.custom_emoji_url.as_deref(),
tag.custom_emoji_label.as_deref(),
false,
);
let style = if tag.selected {
theme::current().style(theme::HighlightGroup::Tag)
} else {
theme::current().style(theme::HighlightGroup::Disabled)
};
lines.push(Line::from(Span::styled(
truncate_display_width(&format!(" {checkbox}{emoji} {}", tag.name), width),
style,
)));
}
let remaining = tags.len().saturating_sub(shown);
if remaining > 0 {
lines.push(Line::from(Span::styled(
truncate_display_width(&format!(" ...(+{remaining} more)"), width),
theme::current().style(theme::HighlightGroup::Hint),
)));
}
}
pub(in crate::tui::ui) fn render_forum_post_tag_picker(
frame: &mut Frame,
area: Rect,
state: &DashboardState,
emoji_images: &[EmojiImage<'_>],
) {
if !state.is_forum_post_tag_picker_active() {
return;
}
let Some(view) = state.forum_post_composer_view() else {
return;
};
if view.tags.is_empty() {
return;
}
let tags = &view.tags;
let popup = forum_post_tag_picker_popup_area(area, tags.len());
let content = render_modal_frame(frame, popup, "Choose tags");
let visible_items = usize::from(content.height)
.min(TAG_PICKER_VISIBLE_ITEMS)
.min(tags.len())
.max(1);
let visible_range = selection::visible_window(view.tag_scroll, visible_items, tags.len());
let ready_urls = super::thread_edit::ready_emoji_urls(emoji_images);
let rows: Vec<Line<'static>> = tags[visible_range.clone()]
.iter()
.map(|tag| {
tag_line(
tag,
usize::from(content.width),
super::thread_edit::tag_custom_emoji_ready(
tag.custom_emoji_url.as_deref(),
&ready_urls,
),
)
})
.collect();
frame.render_widget(Paragraph::new(rows).wrap(Wrap { trim: false }), content);
if state.show_custom_emoji() {
super::thread_edit::render_tag_picker_emojis(
frame,
content,
tags[visible_range.clone()]
.iter()
.map(|tag| tag.custom_emoji_url.as_deref()),
emoji_images,
);
}
render_vertical_scrollbar(
frame,
Rect {
height: visible_items as u16,
..content
},
visible_range.start,
visible_items,
tags.len(),
);
}
fn forum_post_tag_picker_popup_area(area: Rect, tag_count: usize) -> Rect {
let visible = tag_count.clamp(1, TAG_PICKER_VISIBLE_ITEMS) as u16;
centered_rect(area, TAG_PICKER_WIDTH, visible.saturating_add(2))
}
pub(in crate::tui::ui) fn forum_post_tag_picker_visible_items(
area: Rect,
tag_count: usize,
) -> usize {
let popup = forum_post_tag_picker_popup_area(area, tag_count);
let content = panel_block("Choose tags", true).inner(popup);
usize::from(content.height)
.min(TAG_PICKER_VISIBLE_ITEMS)
.min(tag_count)
.max(1)
}
fn render_forum_post_attachment_previews(
frame: &mut Frame,
inner: Rect,
row_in_view: u16,
previews: Vec<LocalUploadPreviewView<'_>>,
) {
let y = inner.y.saturating_add(row_in_view);
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_forum_post_attachment_preview(frame, preview_area, preview);
}
}
fn render_forum_post_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 field_line(
label: &str,
value: &str,
active: bool,
editing: bool,
width: usize,
placeholder: &str,
) -> Line<'static> {
let marker = editable_field_marker(active);
let prefix = format!("{marker}{label}: ");
let available = width.saturating_sub(prefix.width()).max(1);
let content = if value.is_empty() {
Span::styled(
truncate_display_width(placeholder, available),
theme::current().style(theme::HighlightGroup::Placeholder),
)
} else {
Span::styled(
truncate_display_width(value, available),
editable_field_value_style(active, editing),
)
};
Line::from(vec![
Span::styled(prefix, editable_field_label_style(active, editing)),
content,
])
}
fn section_line(label: &str, active: bool, editing: bool) -> Line<'static> {
Line::from(Span::styled(
format!("{}{}", editable_field_marker(active), label),
editable_field_label_style(active, editing),
))
}
fn visible_body_lines(body: &str) -> Vec<&str> {
if body.is_empty() {
Vec::new()
} else {
body.split('\n').collect()
}
}
fn body_cursor_line_column(value: &str, cursor: usize) -> (usize, usize) {
let prefix = cursor_prefix(value, cursor);
let line = prefix.chars().filter(|value| *value == '\n').count();
let column = prefix
.rsplit('\n')
.next()
.map(cursor_column_for_str)
.unwrap_or_default();
(line, column)
}
fn cursor_column(value: &str, cursor: usize) -> usize {
cursor_column_for_str(cursor_prefix(value, cursor))
}
fn cursor_column_for_str(value: &str) -> usize {
value.width()
}
fn cursor_prefix(value: &str, cursor: usize) -> &str {
let mut end = cursor.min(value.len());
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
&value[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::ForumPostComposerView;
fn body_view(body: &str, body_cursor: usize) -> ForumPostComposerView {
ForumPostComposerView {
channel_label: "#support".to_owned(),
active_field: ForumPostComposerField::Body,
editing_field: Some(ForumPostComposerField::Body),
title: String::new(),
title_cursor: 0,
body: body.to_owned(),
body_cursor,
attachments: Vec::new(),
tags: Vec::new(),
tag_scroll: 0,
requires_tag: false,
paste_pending: false,
status: None,
}
}
#[test]
fn body_layout_keeps_every_line_and_tracks_cursor() {
let body = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight";
let view = body_view(body, body.len());
let layout = build_composer_layout(&view, 40, 0);
assert_eq!(layout.attachments_row - layout.body_row, 9);
assert_eq!(layout.cursor, Some((layout.body_content_row + 7, 7)));
}
#[test]
fn cursor_prefix_clamps_to_char_boundary() {
let text = "가나";
assert_eq!(cursor_prefix(text, 1), "");
assert_eq!(cursor_prefix(text, 3), "가");
}
fn tag(name: &str, selected: bool) -> ForumPostComposerTagView {
ForumPostComposerTagView {
name: name.to_owned(),
unicode_emoji: None,
custom_emoji_url: None,
custom_emoji_label: None,
selected,
active: false,
selectable: true,
}
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn tag_summary_shows_at_least_three_then_folds_the_rest() {
let tags: Vec<_> = (0..20)
.map(|index| tag(&format!("t{index}"), false))
.collect();
let mut lines = Vec::new();
push_tag_summary(&mut lines, &tags, 40);
assert_eq!(lines.len(), 4);
assert!(line_text(&lines[3]).contains("...(+17 more)"));
}
#[test]
fn tag_summary_never_reduces_selected_tags() {
let tags: Vec<_> = (0..6)
.map(|index| tag(&format!("t{index}"), index < 5))
.collect();
let mut lines = Vec::new();
push_tag_summary(&mut lines, &tags, 40);
assert_eq!(lines.len(), 6);
assert!(line_text(&lines[5]).contains("...(+1 more)"));
}
#[test]
fn metrics_reveal_target_follows_the_body_cursor() {
let body = "one\ntwo\nthree";
let view = body_view(body, body.len());
let metrics = forum_post_composer_metrics(&view, 40, 0);
assert_eq!((metrics.reveal_start, metrics.reveal_end), (4, 5));
assert!(metrics.total_lines > 5);
}
}