feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it, and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab walk the panes, z zooms the focused one to the window, w hides it so the others widen, and its number brings it back. Every list takes the same navigation keys, the shortcuts bar wraps instead of cutting keys off, and ? lists every key by pane, scrolling on a short terminal. Follows up on #55.
This commit is contained in:
@@ -6,19 +6,92 @@ use crate::ui::panes::{
|
||||
render_rooms_pane, render_searches_pane, selected_transfer,
|
||||
};
|
||||
use crate::ui::{
|
||||
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
|
||||
SIGNAL, accent_style, body_style, dimmed_style, pack_shortcuts, pane_block,
|
||||
plain_title, primary_style, render_download_stats, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Layout, Position, Rect},
|
||||
style::Modifier,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
/// The keys overlay, grouped by where a key applies. Two columns, because a
|
||||
/// single list runs longer than most terminals are tall.
|
||||
const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
|
||||
(
|
||||
"Panes",
|
||||
&[
|
||||
("Tab / Shift-Tab", "next / previous pane"),
|
||||
("1 2 3", "focus a pane, hidden or not"),
|
||||
("z", "zoom the focused pane"),
|
||||
("w", "hide the focused pane"),
|
||||
("Esc", "leave zoom"),
|
||||
("click", "focus a pane"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Anywhere",
|
||||
&[
|
||||
("s", "search the network"),
|
||||
("b", "browse a user's files"),
|
||||
("m", "compose a private message"),
|
||||
("i", "inbox"),
|
||||
("o", "settings"),
|
||||
("?", "this list"),
|
||||
("q", "quit"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Any list",
|
||||
&[
|
||||
("↑ ↓ / k j", "move one row"),
|
||||
("Home End / g G", "first / last row"),
|
||||
("PgUp PgDn / ^b ^f", "one page"),
|
||||
("^u ^d", "half a page"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
||||
(
|
||||
"Searches",
|
||||
&[
|
||||
("Enter", "show its results"),
|
||||
("d", "remove the search"),
|
||||
("C", "clear every search"),
|
||||
("c", "chat rooms"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Results",
|
||||
&[
|
||||
("Space", "select / deselect"),
|
||||
("a / A", "select all / none"),
|
||||
("Enter", "download the selection"),
|
||||
("/", "filter, Enter keeps it, Esc clears"),
|
||||
("h l / ← →", "scroll the file name"),
|
||||
("0 / $", "start / end of the file name"),
|
||||
("b", "browse the owner"),
|
||||
("c", "chat rooms"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Downloads / Uploads",
|
||||
&[
|
||||
("p", "pause / resume"),
|
||||
("x", "cancel"),
|
||||
("r", "retry a failed one"),
|
||||
("d", "delete a queued or finished one"),
|
||||
("c", "clear every finished one"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
impl MainTui {
|
||||
pub(super) 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
|
||||
@@ -26,7 +99,7 @@ impl MainTui {
|
||||
if self.state.command_bar_active {
|
||||
constraints.push(Constraint::Length(3)); // Command bar
|
||||
}
|
||||
constraints.push(Constraint::Length(3)); // Shortcuts
|
||||
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
|
||||
let main_chunks = Layout::vertical(constraints).split(frame.area());
|
||||
|
||||
let daemon = self.client.daemon_endpoint();
|
||||
@@ -43,55 +116,111 @@ impl MainTui {
|
||||
// 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]);
|
||||
self.render_shortcuts(frame, main_chunks[3], shortcuts);
|
||||
} else {
|
||||
self.render_shortcuts(frame, main_chunks[2]);
|
||||
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) {
|
||||
// Split main content area
|
||||
let content_chunks = Layout::horizontal([
|
||||
Constraint::Percentage(30), // Searches pane
|
||||
Constraint::Percentage(70), // Results + Downloads
|
||||
])
|
||||
.split(area);
|
||||
// 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;
|
||||
|
||||
// Store searches pane area
|
||||
self.state.searches_pane_area = Some(content_chunks[0]);
|
||||
if self.state.layout.zoomed {
|
||||
self.render_pane(frame, area, self.state.focused_pane);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render Searches pane (left)
|
||||
render_searches_pane(
|
||||
frame,
|
||||
content_chunks[0],
|
||||
&self.state.searches,
|
||||
&mut self.state.searches_table_state,
|
||||
self.state.focused_pane == FocusedPane::Searches,
|
||||
);
|
||||
let row_panes: Vec<FocusedPane> =
|
||||
[FocusedPane::Searches, FocusedPane::Downloads]
|
||||
.into_iter()
|
||||
.filter(|pane| self.state.layout.is_visible(*pane))
|
||||
.collect();
|
||||
|
||||
// Split right side into Results (top) and Downloads (bottom)
|
||||
let right_chunks = Layout::vertical([
|
||||
Constraint::Percentage(60), // Results
|
||||
Constraint::Percentage(40), // Downloads
|
||||
])
|
||||
.split(content_chunks[1]);
|
||||
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, bottom] = Layout::vertical([
|
||||
Constraint::Fill(3),
|
||||
Constraint::Fill(row_weight),
|
||||
])
|
||||
.areas(area);
|
||||
self.render_pane(frame, top, FocusedPane::Results);
|
||||
bottom
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
// Split bottom-right into Downloads list and Info pane
|
||||
let downloads_chunks = Layout::horizontal([
|
||||
Constraint::Percentage(60), // Downloads list
|
||||
Constraint::Percentage(40), // Info pane
|
||||
])
|
||||
.split(right_chunks[1]);
|
||||
// Transfers carry the most columns, so they get the most width.
|
||||
let mut constraints: Vec<Constraint> = row_panes
|
||||
.iter()
|
||||
.map(|pane| match pane {
|
||||
FocusedPane::Searches => Constraint::Fill(5),
|
||||
FocusedPane::Downloads => Constraint::Fill(10),
|
||||
FocusedPane::Results => unreachable!("not a row pane"),
|
||||
})
|
||||
.collect();
|
||||
constraints.push(Constraint::Fill(6)); // Info
|
||||
let chunks = Layout::horizontal(constraints).split(row_area);
|
||||
for (chunk, pane) in chunks.iter().zip(&row_panes) {
|
||||
self.render_pane(frame, *chunk, *pane);
|
||||
}
|
||||
self.render_info_pane(frame, chunks[row_panes.len()]);
|
||||
}
|
||||
|
||||
// Store results and downloads pane areas
|
||||
self.state.results_pane_area = Some(right_chunks[0]);
|
||||
self.state.downloads_pane_area = Some(downloads_chunks[0]);
|
||||
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,
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render Results pane. 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.
|
||||
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)
|
||||
@@ -110,7 +239,7 @@ impl MainTui {
|
||||
|
||||
render_results_pane(
|
||||
frame,
|
||||
right_chunks[0],
|
||||
area,
|
||||
ResultsPaneParams {
|
||||
items: results_items,
|
||||
table_state: &mut self.state.results_table_state,
|
||||
@@ -118,21 +247,16 @@ impl MainTui {
|
||||
original_indices: results_original_indices,
|
||||
filter_query: &self.state.results_filter_query,
|
||||
is_filtering: self.state.results_is_filtering,
|
||||
focused: self.state.focused_pane == FocusedPane::Results,
|
||||
focused,
|
||||
active_search_query,
|
||||
name_offset: self.state.results_name_offset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
render_downloads_pane(
|
||||
frame,
|
||||
downloads_chunks[0],
|
||||
&self.state.downloads,
|
||||
&self.state.uploads,
|
||||
&mut self.state.downloads_table_state,
|
||||
self.state.focused_pane == FocusedPane::Downloads,
|
||||
);
|
||||
|
||||
/// 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 {
|
||||
@@ -144,7 +268,7 @@ impl MainTui {
|
||||
};
|
||||
render_download_info_pane(
|
||||
frame,
|
||||
downloads_chunks[1],
|
||||
area,
|
||||
selected,
|
||||
matches!(
|
||||
self.state.focused_pane,
|
||||
@@ -153,6 +277,49 @@ impl MainTui {
|
||||
);
|
||||
}
|
||||
|
||||
/// The keys list, two columns side by side where the terminal is wide
|
||||
/// enough and one on top of the other where it is not, scrolling when
|
||||
/// even that is taller than the window.
|
||||
fn render_help_popup(&mut self, frame: &mut Frame) {
|
||||
let screen = frame.area();
|
||||
// Borders and padding on either side of the text.
|
||||
let frame_width = 4;
|
||||
let width = screen.width.min(help_width() + frame_width);
|
||||
let columns = help_columns(width.saturating_sub(frame_width));
|
||||
let tallest = columns.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let height = screen
|
||||
.height
|
||||
.min(u16::try_from(tallest + 2).unwrap_or(u16::MAX));
|
||||
let area = Rect::new(
|
||||
screen.x + (screen.width - width) / 2,
|
||||
screen.y + (screen.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
|
||||
let block = pane_block(true).title(plain_title("Keys", true));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// Both columns scroll together, and no further than the taller one
|
||||
// needs; the clamp is written back so the keys cannot run past it.
|
||||
let furthest = tallest.saturating_sub(usize::from(inner.height));
|
||||
self.state.help_scroll = self.state.help_scroll.min(furthest);
|
||||
let scroll = u16::try_from(self.state.help_scroll).unwrap_or(u16::MAX);
|
||||
|
||||
let areas =
|
||||
Layout::horizontal(vec![Constraint::Fill(1); columns.len()])
|
||||
.spacing(HELP_GAP)
|
||||
.split(inner);
|
||||
for (area, lines) in areas.iter().zip(columns) {
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).scroll((scroll, 0)),
|
||||
*area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overlays(&mut self, frame: &mut Frame) {
|
||||
// Messages inbox overlays everything when open.
|
||||
if self.state.show_messages {
|
||||
@@ -188,6 +355,12 @@ impl MainTui {
|
||||
&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) {
|
||||
@@ -304,10 +477,31 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys every pane shares for moving between and resizing panes,
|
||||
/// ending the bar the same way wherever the focus sits.
|
||||
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("Tab/1-3", "pane"),
|
||||
(
|
||||
"z",
|
||||
if self.state.layout.zoomed {
|
||||
"unzoom"
|
||||
} else {
|
||||
"zoom"
|
||||
},
|
||||
),
|
||||
("w", "hide"),
|
||||
("?", "keys"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Which shortcuts the bar offers, which is purely a question of what is
|
||||
/// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`].
|
||||
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> {
|
||||
if self.state.settings.is_some() {
|
||||
if self.state.show_help {
|
||||
vec![("?/Esc", "close")]
|
||||
} else if self.state.settings.is_some() {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
("Enter/e", "edit download dir"),
|
||||
@@ -364,29 +558,28 @@ impl MainTui {
|
||||
("Esc", "cancel"),
|
||||
],
|
||||
}
|
||||
} else if self.state.results_is_filtering
|
||||
&& self.state.focused_pane == FocusedPane::Results
|
||||
{
|
||||
// Typing goes to the filter, so no letter keys are on offer here.
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("↑/↓", "navigate"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
} else {
|
||||
match self.state.focused_pane {
|
||||
let mut keys = match self.state.focused_pane {
|
||||
FocusedPane::Searches => vec![
|
||||
("s", "search"),
|
||||
("Enter", "results"),
|
||||
("d", "remove"),
|
||||
("m", "message"),
|
||||
("i", "inbox"),
|
||||
("c", "chat"),
|
||||
("b", "browse user"),
|
||||
("1-3", "focus pane"),
|
||||
("↑↓", "navigate"),
|
||||
("Enter", "results"),
|
||||
("q", "quit"),
|
||||
],
|
||||
FocusedPane::Results if self.state.results_is_filtering => {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("↑/↓", "navigate"),
|
||||
("Enter", "confirm"),
|
||||
("Esc", "clear filter"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
FocusedPane::Results => vec![
|
||||
("Space", "select"),
|
||||
("Enter", "download"),
|
||||
@@ -394,29 +587,50 @@ impl MainTui {
|
||||
("c", "chat"),
|
||||
("/", "filter"),
|
||||
("a/A", "select all/none"),
|
||||
("h/l", "scroll"),
|
||||
("h/l", "scroll name"),
|
||||
("g/G", "top/end"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
],
|
||||
FocusedPane::Downloads => {
|
||||
vec![
|
||||
("p", "pause/resume"),
|
||||
("x", "cancel"),
|
||||
("r", "retry failed"),
|
||||
("d", "delete queued/done"),
|
||||
("c", "clear finished"),
|
||||
("b", "browse user"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
}
|
||||
FocusedPane::Downloads => vec![
|
||||
("p", "pause/resume"),
|
||||
("x", "cancel"),
|
||||
("r", "retry failed"),
|
||||
("d", "delete queued/done"),
|
||||
("c", "clear finished"),
|
||||
("b", "browse user"),
|
||||
],
|
||||
};
|
||||
keys.extend(self.pane_shortcuts());
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
||||
let shortcuts_line = format_shortcuts_styled(&self.shortcuts());
|
||||
/// The legend packed into rows for a window `width` wide, so a narrow
|
||||
/// terminal grows the bar instead of cutting keys off its right edge.
|
||||
fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
|
||||
// Borders and padding, and the unread badge's column when it shows.
|
||||
let badge = self.unread_badge().width() as u16;
|
||||
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
|
||||
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved))
|
||||
}
|
||||
|
||||
/// A prominent unread badge for the shortcuts bar. It rides in the
|
||||
/// always-visible bar, so unread mail/chat shows in every pane, not just
|
||||
/// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright
|
||||
/// for the first half of the cycle, dim for the second.
|
||||
fn unread_badge(&self) -> Line<'static> {
|
||||
unread_indicator(
|
||||
self.state.unread_messages,
|
||||
self.state.rooms.total_unread(),
|
||||
self.spinner_state < 5,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_shortcuts(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
rows: Vec<Line<'static>>,
|
||||
) {
|
||||
// Surface our own sharing status in the block title.
|
||||
let shared = self.client.shared_directories();
|
||||
let sharing = match shared.as_slice() {
|
||||
@@ -431,26 +645,20 @@ impl MainTui {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// A prominent unread badge, right-aligned so the context shortcuts on
|
||||
// the left never crowd it out. It rides in the always-visible shortcuts
|
||||
// bar, so unread mail/chat shows in every pane, not just some. The
|
||||
// spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the
|
||||
// first half of the cycle, dim for the second.
|
||||
let unread = unread_indicator(
|
||||
self.state.unread_messages,
|
||||
self.state.rooms.total_unread(),
|
||||
self.spinner_state < 5,
|
||||
);
|
||||
// The unread badge is right-aligned so the context shortcuts on the
|
||||
// left never crowd it out.
|
||||
let unread = self.unread_badge();
|
||||
if unread.spans.is_empty() {
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), inner);
|
||||
frame.render_widget(Paragraph::new(rows), inner);
|
||||
} else {
|
||||
let badge_width = unread.width() as u16;
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(badge_width),
|
||||
])
|
||||
.spacing(1)
|
||||
.split(inner);
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]);
|
||||
frame.render_widget(Paragraph::new(rows), cols[0]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(unread).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
@@ -493,6 +701,72 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns between the two halves of the keys list.
|
||||
const HELP_GAP: u16 = 2;
|
||||
|
||||
/// The widest key in a column, so the descriptions line up.
|
||||
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
|
||||
sections
|
||||
.iter()
|
||||
.flat_map(|(_, keys)| keys.iter())
|
||||
.map(|(key, _)| Span::raw(*key).width())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cells a column of the keys list needs to show every line whole.
|
||||
fn help_column_width(sections: &[(&str, &[(&str, &str)])]) -> u16 {
|
||||
let widest = help_lines(sections)
|
||||
.iter()
|
||||
.map(Line::width)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
u16::try_from(widest).unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
/// The text width of the keys list with both halves side by side.
|
||||
fn help_width() -> u16 {
|
||||
help_column_width(HELP_LEFT) + HELP_GAP + help_column_width(HELP_RIGHT)
|
||||
}
|
||||
|
||||
/// The keys list laid out for `width` cells of text: two columns when both
|
||||
/// fit beside each other, otherwise the right half under the left.
|
||||
fn help_columns(width: u16) -> Vec<Vec<Line<'static>>> {
|
||||
let left = help_lines(HELP_LEFT);
|
||||
let right = help_lines(HELP_RIGHT);
|
||||
if width >= help_width() {
|
||||
return vec![left, right];
|
||||
}
|
||||
let mut stacked = left;
|
||||
stacked.push(Line::from(""));
|
||||
stacked.extend(right);
|
||||
vec![stacked]
|
||||
}
|
||||
|
||||
fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
|
||||
let key_width = key_column_width(sections);
|
||||
let mut lines = Vec::new();
|
||||
for (index, (heading, keys)) in sections.iter().enumerate() {
|
||||
if index > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.push(Line::styled(
|
||||
(*heading).to_string(),
|
||||
accent_style().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
for (key, action) in *keys {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{key:<key_width$} "),
|
||||
Style::default().fg(SIGNAL),
|
||||
),
|
||||
Span::styled((*action).to_string(), body_style()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// 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([
|
||||
|
||||
Reference in New Issue
Block a user