521 lines
19 KiB
Rust
521 lines
19 KiB
Rust
mod shortcuts;
|
|
|
|
use super::MainTui;
|
|
use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView};
|
|
use crate::ui::panes::{
|
|
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
|
|
render_download_info_pane, render_downloads_pane, render_results_pane,
|
|
render_rooms_pane, render_searches_pane, selected_transfer,
|
|
};
|
|
use crate::ui::{
|
|
GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask,
|
|
pack_shortcuts, pane_block, plain_title, primary_style,
|
|
render_download_stats, warning_style,
|
|
};
|
|
use ratatui::{
|
|
Frame,
|
|
layout::{Alignment, Constraint, Layout, Position, Rect},
|
|
style::Modifier,
|
|
text::{Line, Span},
|
|
widgets::Paragraph,
|
|
};
|
|
impl MainTui {
|
|
/// Draw the whole window.
|
|
pub fn render(&mut self, frame: &mut Frame) {
|
|
let shortcuts = self.shortcut_rows(frame.area().width);
|
|
let mut constraints = vec![
|
|
Constraint::Length(3), // Status bar
|
|
Constraint::Fill(1), // Main content
|
|
];
|
|
if self.state.command_bar_active {
|
|
constraints.push(Constraint::Length(3)); // Command bar
|
|
}
|
|
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
|
|
let main_chunks = Layout::vertical(constraints).split(frame.area());
|
|
|
|
let daemon = self.client.daemon_endpoint();
|
|
render_download_stats(
|
|
frame,
|
|
main_chunks[0],
|
|
&self.state.downloads,
|
|
self.state.active_downloads_count,
|
|
daemon.as_deref(),
|
|
self.listener_fallback,
|
|
);
|
|
|
|
self.render_content(frame, main_chunks[1]);
|
|
|
|
// Render command bar if active (vim-style, above shortcuts)
|
|
if self.state.command_bar_active {
|
|
self.render_command_bar(frame, main_chunks[2]);
|
|
self.render_shortcuts(frame, main_chunks[3], shortcuts);
|
|
} else {
|
|
self.render_shortcuts(frame, main_chunks[2], shortcuts);
|
|
}
|
|
|
|
self.render_overlays(frame);
|
|
}
|
|
|
|
/// Results across the top, where a long file name has the whole width;
|
|
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
|
|
/// out of its row and the rest widen; a zoomed one has the area alone.
|
|
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
|
|
// Only what is drawn can be clicked.
|
|
self.state.searches_pane_area = None;
|
|
self.state.results_pane_area = None;
|
|
self.state.downloads_pane_area = None;
|
|
self.state.info_pane_area = None;
|
|
self.state.hsplit_divider = None;
|
|
self.state.vsplit_dividers.clear();
|
|
self.state.row_area = None;
|
|
|
|
if self.state.layout.zoomed {
|
|
self.render_pane(frame, area, self.state.focused_pane);
|
|
return;
|
|
}
|
|
self.state.content_area = Some(area);
|
|
|
|
// Transfers carry the most columns, so they get the most width.
|
|
let row_panes: Vec<(FocusedPane, u16)> =
|
|
[(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)]
|
|
.into_iter()
|
|
.filter(|(pane, _)| self.state.layout.is_visible(*pane))
|
|
.collect();
|
|
|
|
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
|
|
// With only Info left in the row, the results deserve more of
|
|
// the height.
|
|
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
|
|
let top = match self.state.layout.top_height {
|
|
Some(rows) => Constraint::Length(shrink(rows, area.height)),
|
|
None => Constraint::Fill(3),
|
|
};
|
|
let [top, bottom] =
|
|
Layout::vertical([top, Constraint::Fill(row_weight)])
|
|
.areas(area);
|
|
// The two touching border rows between the halves; either can
|
|
// be dragged.
|
|
self.state.hsplit_divider = Some(Rect::new(
|
|
area.x,
|
|
top.y.saturating_add(top.height).saturating_sub(1),
|
|
area.width,
|
|
2,
|
|
));
|
|
self.render_pane(frame, top, FocusedPane::Results);
|
|
bottom
|
|
} else {
|
|
area
|
|
};
|
|
self.state.row_area = Some(row_area);
|
|
|
|
let mut constraints: Vec<Constraint> = row_panes
|
|
.iter()
|
|
.map(|(pane, weight)| {
|
|
let dragged = match *pane {
|
|
FocusedPane::Searches => self.state.layout.searches_width,
|
|
FocusedPane::Downloads => self.state.layout.downloads_width,
|
|
FocusedPane::Results => None,
|
|
};
|
|
match dragged {
|
|
Some(cols) => {
|
|
Constraint::Length(shrink(cols, row_area.width))
|
|
}
|
|
None => Constraint::Fill(*weight),
|
|
}
|
|
})
|
|
.collect();
|
|
constraints.push(Constraint::Fill(6)); // Info
|
|
let chunks = Layout::horizontal(constraints).split(row_area);
|
|
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) {
|
|
// The seam at this pane's right edge: its border column and the
|
|
// next pane's, both draggable.
|
|
self.state.vsplit_dividers.push((
|
|
*pane,
|
|
Rect::new(
|
|
chunk.x.saturating_add(chunk.width).saturating_sub(1),
|
|
row_area.y,
|
|
2,
|
|
row_area.height,
|
|
),
|
|
));
|
|
self.render_pane(frame, *chunk, *pane);
|
|
}
|
|
self.state.info_pane_area = Some(chunks[row_panes.len()]);
|
|
self.render_info_pane(frame, chunks[row_panes.len()]);
|
|
}
|
|
|
|
fn render_pane(
|
|
&mut self,
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
pane: FocusedPane,
|
|
) {
|
|
let focused = self.state.focused_pane == pane;
|
|
match pane {
|
|
FocusedPane::Searches => {
|
|
self.state.searches_pane_area = Some(area);
|
|
render_searches_pane(
|
|
frame,
|
|
area,
|
|
&self.state.searches,
|
|
&mut self.state.searches_table_state,
|
|
focused,
|
|
self.state.searches_query_offset,
|
|
);
|
|
}
|
|
FocusedPane::Results => {
|
|
self.state.results_pane_area = Some(area);
|
|
self.render_results_pane(frame, area, focused);
|
|
}
|
|
FocusedPane::Downloads => {
|
|
self.state.downloads_pane_area = Some(area);
|
|
render_downloads_pane(
|
|
frame,
|
|
area,
|
|
&self.state.downloads,
|
|
&self.state.uploads,
|
|
&mut self.state.downloads_table_state,
|
|
focused,
|
|
self.state.downloads_name_offset,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_results_pane(
|
|
&mut self,
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
focused: bool,
|
|
) {
|
|
// When a filter is active the rendered rows are a subset, so the pane
|
|
// also needs the mapping back to unfiltered indices to render the
|
|
// selection checkboxes correctly.
|
|
let (results_items, results_original_indices) =
|
|
if self.state.results_filter_query.is_empty() {
|
|
(&self.state.results_items, None)
|
|
} else {
|
|
(
|
|
&self.state.results_filtered_items,
|
|
Some(self.state.results_filtered_indices.as_slice()),
|
|
)
|
|
};
|
|
|
|
let active_search_query = self
|
|
.state
|
|
.selected_search_index
|
|
.and_then(|idx| self.state.searches.get(idx))
|
|
.map(|search| search.query.as_str());
|
|
|
|
render_results_pane(
|
|
frame,
|
|
area,
|
|
ResultsPaneParams {
|
|
items: results_items,
|
|
table_state: &mut self.state.results_table_state,
|
|
selected_indices: &self.state.results_selected_indices,
|
|
original_indices: results_original_indices,
|
|
filter_query: &self.state.results_filter_query,
|
|
is_filtering: self.state.results_is_filtering,
|
|
focused,
|
|
active_search_query,
|
|
name_offset: self.state.results_name_offset,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The Info pane follows the focus: the highlighted result, or the
|
|
/// highlighted transfer.
|
|
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
|
|
let selected = if self.state.focused_pane == FocusedPane::Results {
|
|
self.highlighted_result().map(InfoSubject::Result)
|
|
} else {
|
|
selected_transfer(
|
|
self.state.downloads_table_state.selected(),
|
|
&self.state.downloads,
|
|
&self.state.uploads,
|
|
)
|
|
};
|
|
render_download_info_pane(
|
|
frame,
|
|
area,
|
|
selected,
|
|
matches!(
|
|
self.state.focused_pane,
|
|
FocusedPane::Results | FocusedPane::Downloads
|
|
),
|
|
);
|
|
}
|
|
fn render_overlays(&mut self, frame: &mut Frame) {
|
|
// Messages inbox overlays everything when open.
|
|
if self.state.show_messages {
|
|
self.render_messages_popup(frame);
|
|
}
|
|
|
|
// Browse tree overlays everything when open.
|
|
if self.state.show_browse && !self.state.browse.is_empty() {
|
|
let area = centered_rect(80, 80, frame.area());
|
|
self.state.popup_area = Some(area);
|
|
frame.render_widget(ratatui::widgets::Clear, area);
|
|
render_browse_pane(
|
|
frame,
|
|
area,
|
|
&self.state.browse,
|
|
&mut self.state.browse_table_state,
|
|
self.spinner_state,
|
|
&self.state.downloads,
|
|
);
|
|
}
|
|
|
|
// Settings popup overlays everything when open.
|
|
if self.state.settings.is_some() {
|
|
self.render_settings_popup(frame);
|
|
}
|
|
|
|
// Chat rooms overlay everything when open.
|
|
if self.state.show_rooms {
|
|
let area = centered_rect(85, 80, frame.area());
|
|
self.state.popup_area = Some(area);
|
|
frame.render_widget(ratatui::widgets::Clear, area);
|
|
render_rooms_pane(
|
|
frame,
|
|
area,
|
|
&mut self.state.rooms,
|
|
&mut self.state.rooms_list_table_state,
|
|
);
|
|
}
|
|
|
|
// The keys list only opens from the main view, so it is on top of
|
|
// nothing; drawn last so that stays true if that ever changes.
|
|
if self.state.show_help {
|
|
self.render_help_popup(frame);
|
|
}
|
|
}
|
|
|
|
fn render_settings_popup(&self, frame: &mut Frame) {
|
|
use crate::models::{SettingsMode, SettingsRow};
|
|
let Some(settings) = self.state.settings.as_ref() else {
|
|
return;
|
|
};
|
|
let area = centered_rect(70, 60, frame.area());
|
|
frame.render_widget(ratatui::widgets::Clear, area);
|
|
|
|
let selected = settings.selected_row();
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
let entry = |on: bool, label: &str, value: String| {
|
|
Line::from(vec![
|
|
Span::styled(if on { "> " } else { " " }, accent_style()),
|
|
Span::styled(label.to_string(), dimmed_style()),
|
|
Span::styled(value, primary_style()),
|
|
])
|
|
};
|
|
let typing = |label: &str, value: String| {
|
|
Line::from(vec![
|
|
Span::styled("> ", accent_style()),
|
|
Span::styled(label.to_string(), dimmed_style()),
|
|
Span::styled(value, primary_style()),
|
|
Span::styled(GLYPH_CURSOR, accent_style()),
|
|
])
|
|
};
|
|
let section = |name: &str| Line::styled(name.to_string(), info_style());
|
|
|
|
// One column of labels, padded here rather than by hand in each
|
|
// string.
|
|
let field = |label: &str, value: String| {
|
|
entry(false, &format!(" {label:<14}"), value)
|
|
};
|
|
let account = &settings.account;
|
|
lines.push(section("Account"));
|
|
lines.push(field("Signed in as", account.username.clone()));
|
|
lines.push(field("Sharing", account.shares.clone()));
|
|
lines.push(field(
|
|
"Reachable on",
|
|
account.listen_port.map_or_else(
|
|
|| "not accepting connections".to_string(),
|
|
|port| format!("port {port}"),
|
|
),
|
|
));
|
|
if let Some(daemon) = &account.daemon {
|
|
lines.push(field("Daemon", daemon.clone()));
|
|
}
|
|
lines.push(Line::from(""));
|
|
|
|
lines.push(match settings.mode {
|
|
SettingsMode::NewPassword => {
|
|
typing("New password: ", mask(&settings.input))
|
|
}
|
|
SettingsMode::RepeatPassword(_) => {
|
|
typing("Repeat it: ", mask(&settings.input))
|
|
}
|
|
_ => entry(
|
|
selected == SettingsRow::ChangePassword,
|
|
"Change password…",
|
|
String::new(),
|
|
),
|
|
});
|
|
if settings.mode == SettingsMode::ConfirmingLogout {
|
|
lines.push(entry(
|
|
true,
|
|
&format!("Log out of {}? ", account.username),
|
|
"y / n".to_string(),
|
|
));
|
|
} else if settings.can_log_out() {
|
|
lines.push(entry(
|
|
selected == SettingsRow::Logout,
|
|
"Log out…",
|
|
String::new(),
|
|
));
|
|
}
|
|
lines.push(Line::from(""));
|
|
|
|
lines.push(section("Folders"));
|
|
lines.push(entry(
|
|
selected == SettingsRow::UseUsernameDirs,
|
|
"Use username directory ",
|
|
if settings.use_username_dirs {
|
|
"[x]"
|
|
} else {
|
|
"[ ]"
|
|
}
|
|
.to_string(),
|
|
));
|
|
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
|
|
typing("Download folder: ", settings.input.clone())
|
|
} else {
|
|
entry(
|
|
selected == SettingsRow::DownloadDir,
|
|
"Download folder: ",
|
|
settings.download_dir.clone(),
|
|
)
|
|
});
|
|
lines.push(Line::styled(
|
|
format!("Shared folders ({}):", settings.share_dirs.len()),
|
|
dimmed_style(),
|
|
));
|
|
for (i, dir) in settings.share_dirs.iter().enumerate() {
|
|
lines.push(entry(
|
|
selected == SettingsRow::Share(i),
|
|
"",
|
|
dir.clone(),
|
|
));
|
|
}
|
|
if settings.share_dirs.is_empty() {
|
|
lines.push(Line::styled(
|
|
" (nothing shared — press 'a' to add a folder)",
|
|
dimmed_style(),
|
|
));
|
|
}
|
|
if settings.mode == SettingsMode::AddingShare {
|
|
lines.push(typing(" Add share: ", settings.input.clone()));
|
|
}
|
|
if let Some(status) = &settings.status {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::styled(status.clone(), warning_style()));
|
|
}
|
|
|
|
let block = pane_block(true).title(plain_title("Settings", true));
|
|
frame.render_widget(
|
|
ratatui::widgets::Paragraph::new(lines)
|
|
.block(block)
|
|
.wrap(ratatui::widgets::Wrap { trim: false }),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn render_messages_popup(&mut self, frame: &mut Frame) {
|
|
// The per-conversation chat box supersedes the old flat message list;
|
|
// it renders the messages and the compose line itself.
|
|
let area = centered_rect(70, 60, frame.area());
|
|
frame.render_widget(ratatui::widgets::Clear, area);
|
|
self.state.popup_area = Some(area);
|
|
let own = self.client.username();
|
|
render_chat_pane(frame, area, &mut self.state, &own);
|
|
}
|
|
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
|
let prefix = match self.state.command_bar_mode {
|
|
CommandBarMode::Search => "search: ",
|
|
CommandBarMode::Message => "message (to: recipient text): ",
|
|
CommandBarMode::Browse => "browse user: ",
|
|
};
|
|
let block = pane_block(true);
|
|
// Derive from the block's inner rect so borders and padding stay in
|
|
// one place; the cursor follows whatever the pane reserves.
|
|
let inner = block.inner(area);
|
|
let prefix_width = prefix.chars().count() as u16;
|
|
let input_width = inner.width.saturating_sub(prefix_width);
|
|
let (visible_input, cursor_column) = visible_input_at_cursor(
|
|
&self.state.command_bar_input,
|
|
self.state.command_bar_cursor_position,
|
|
input_width,
|
|
);
|
|
let command_line = Line::from(vec![
|
|
Span::styled(prefix, accent_style()),
|
|
Span::styled(visible_input, primary_style()),
|
|
]);
|
|
|
|
frame.render_widget(Paragraph::new(command_line).block(block), area);
|
|
|
|
if inner.width > 0 && inner.height > 0 {
|
|
let cursor_x = inner
|
|
.x
|
|
.saturating_add(prefix_width)
|
|
.saturating_add(cursor_column)
|
|
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
|
|
frame.set_cursor_position(Position::new(cursor_x, inner.y));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A dragged pane size kept inside the space the terminal now offers: never
|
|
/// below [`MIN_PANE`], and never so big that what shares the split with it
|
|
/// drops below `MIN_PANE` too.
|
|
pub(super) fn shrink(dragged: u16, total: u16) -> u16 {
|
|
let max = total.saturating_sub(MIN_PANE).max(MIN_PANE);
|
|
dragged.max(MIN_PANE).min(max)
|
|
}
|
|
|
|
/// The smallest pane a drag can leave anyone: a border and a row or column
|
|
/// of content each side.
|
|
pub(super) const MIN_PANE: u16 = 3;
|
|
/// A `Rect` centered within `area`, sized to the given percentages.
|
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|
let vertical = Layout::vertical([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(area);
|
|
Layout::horizontal([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(vertical[1])[1]
|
|
}
|
|
fn visible_input_at_cursor(
|
|
input: &str,
|
|
cursor_position: usize,
|
|
width: u16,
|
|
) -> (String, u16) {
|
|
if width == 0 {
|
|
return (String::new(), 0);
|
|
}
|
|
|
|
let cursor_position = input.floor_char_boundary(cursor_position);
|
|
let cursor_character_index = input[..cursor_position].chars().count();
|
|
let max_cursor_column = usize::from(width.saturating_sub(1));
|
|
let start_character_index =
|
|
cursor_character_index.saturating_sub(max_cursor_column);
|
|
|
|
let visible_input = input
|
|
.chars()
|
|
.skip(start_character_index)
|
|
.take(usize::from(width))
|
|
.collect();
|
|
let cursor_column = cursor_character_index
|
|
.saturating_sub(start_character_index)
|
|
.min(max_cursor_column);
|
|
|
|
(visible_input, cursor_column as u16)
|
|
}
|