feat(ui): theme the TUI with the soulseek-rs design system
Replace the ANSI Light* palette with the design system's tokens (tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm semantics, paper text on dust chrome. Only foregrounds and the row highlight are painted so the user's terminal background shows through. Route every bordered pane through a single pane_block() helper carrying one column of horizontal padding, and give legends a consistent [n] Title form with the tag in signal. The command bar now derives its cursor position from the block's inner rect instead of a hardcoded border offset. Also collapses the download-status glyph match that was duplicated across three render sites into download_status_glyph().
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
use crate::models::FileDisplayData;
|
use crate::models::FileDisplayData;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, border_style, border_type, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, format_bytes, format_shortcuts_styled,
|
||||||
format_shortcuts_styled, get_bitrate, get_spinner_char, header_style,
|
get_bitrate, get_spinner_char, header_style, highlight_style, pane_block,
|
||||||
highlight_style, primary_style, success_style, warning_style,
|
plain_title, primary_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -12,8 +12,8 @@ use ratatui::{
|
|||||||
layout::{Alignment, Constraint, Layout},
|
layout::{Alignment, Constraint, Layout},
|
||||||
style::Style,
|
style::Style,
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget,
|
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
|
||||||
Table, TableState, Wrap,
|
TableState, Wrap,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use soulseek_rs::Client;
|
use soulseek_rs::Client;
|
||||||
@@ -466,13 +466,7 @@ impl FileSelector {
|
|||||||
|
|
||||||
let table = Table::new(rows, widths)
|
let table = Table::new(rows, widths)
|
||||||
.header(header)
|
.header(header)
|
||||||
.block(
|
.block(pane_block(true).title(title))
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(true))
|
|
||||||
.border_type(border_type(true))
|
|
||||||
.title(title),
|
|
||||||
)
|
|
||||||
.column_spacing(1)
|
.column_spacing(1)
|
||||||
.row_highlight_style(highlight_style())
|
.row_highlight_style(highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
@@ -538,12 +532,7 @@ impl FileSelector {
|
|||||||
|
|
||||||
let loading_widget = Paragraph::new(loading_message_line)
|
let loading_widget = Paragraph::new(loading_message_line)
|
||||||
.style(Style::default())
|
.style(Style::default())
|
||||||
.block(
|
.block(pane_block(false))
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(primary_style())
|
|
||||||
.border_type(border_type(false)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.wrap(Wrap { trim: true });
|
.wrap(Wrap { trim: true });
|
||||||
|
|
||||||
@@ -578,12 +567,7 @@ impl FileSelector {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let info_widget = Paragraph::new(info_text)
|
let info_widget = Paragraph::new(info_text)
|
||||||
.block(
|
.block(pane_block(false).title(plain_title(title, false)))
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title(title),
|
|
||||||
)
|
|
||||||
.style(style);
|
.style(style);
|
||||||
|
|
||||||
frame.render_widget(info_widget, info_area);
|
frame.render_widget(info_widget, info_area);
|
||||||
@@ -617,12 +601,8 @@ impl FileSelector {
|
|||||||
])
|
])
|
||||||
};
|
};
|
||||||
|
|
||||||
let controls_widget = Paragraph::new(controls_line).block(
|
let controls_widget = Paragraph::new(controls_line)
|
||||||
Block::default()
|
.block(pane_block(false).title(plain_title("Controls", false)));
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title("Controls"),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(controls_widget, area);
|
frame.render_widget(controls_widget, area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::models::DownloadEntry;
|
use crate::models::DownloadEntry;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, COLOR_PRIMARY, HIGHLIGHT_SYMBOL, border_style, border_type,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style,
|
||||||
error_style, format_bytes_progress, format_progress_bar,
|
download_status_glyph, error_style, format_bytes_progress,
|
||||||
format_shortcuts_styled, format_speed, header_style, highlight_style,
|
format_progress_bar, format_shortcuts_styled, format_speed, header_style,
|
||||||
inactive_style, info_style, primary_style, warning_style,
|
highlight_style, info_style, pane_block, plain_title, primary_style,
|
||||||
|
success_style, warning_style,
|
||||||
};
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -13,8 +14,8 @@ use ratatui::{
|
|||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget,
|
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
|
||||||
Table, TableState,
|
TableState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use soulseek_rs::{Client, DownloadStatus};
|
use soulseek_rs::{Client, DownloadStatus};
|
||||||
@@ -221,14 +222,8 @@ impl MultiDownloadProgress {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|download_entry| {
|
.map(|download_entry| {
|
||||||
let download = &download_entry.download;
|
let download = &download_entry.download;
|
||||||
let status_icon = match download.status {
|
let (status_icon, status_style) =
|
||||||
DownloadStatus::Queued => "⋯",
|
download_status_glyph(&download.status);
|
||||||
DownloadStatus::InProgress { .. } => "⧗",
|
|
||||||
DownloadStatus::Paused { .. } => "⏸",
|
|
||||||
DownloadStatus::Completed => "✓",
|
|
||||||
DownloadStatus::Failed(_) => "✗",
|
|
||||||
DownloadStatus::TimedOut => "⏱",
|
|
||||||
};
|
|
||||||
|
|
||||||
let progress = if download.size > 0 {
|
let progress = if download.size > 0 {
|
||||||
download.bytes_downloaded() as f64 / download.size as f64
|
download.bytes_downloaded() as f64 / download.size as f64
|
||||||
@@ -254,25 +249,15 @@ impl MultiDownloadProgress {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cells = vec![
|
let cells = vec![
|
||||||
Cell::from(status_icon),
|
Cell::from(status_icon).style(status_style),
|
||||||
Cell::from(download.filename.clone()),
|
Cell::from(download.filename.clone()).style(body_style()),
|
||||||
Cell::from(download.username.clone()),
|
Cell::from(download.username.clone()).style(info_style()),
|
||||||
Cell::from(size_str),
|
Cell::from(size_str).style(warning_style()),
|
||||||
Cell::from(progress_bar),
|
Cell::from(progress_bar),
|
||||||
Cell::from(speed_str),
|
Cell::from(speed_str).style(dimmed_style()),
|
||||||
];
|
];
|
||||||
|
|
||||||
let style = match download.status {
|
Row::new(cells).height(1)
|
||||||
DownloadStatus::Queued => inactive_style(),
|
|
||||||
DownloadStatus::InProgress { .. } => warning_style(),
|
|
||||||
DownloadStatus::Paused { .. } => info_style(),
|
|
||||||
DownloadStatus::Completed => primary_style(),
|
|
||||||
DownloadStatus::Failed(_) | DownloadStatus::TimedOut => {
|
|
||||||
error_style()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Row::new(cells).style(style).height(1)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -287,13 +272,7 @@ impl MultiDownloadProgress {
|
|||||||
|
|
||||||
let table = Table::new(rows, widths)
|
let table = Table::new(rows, widths)
|
||||||
.header(header)
|
.header(header)
|
||||||
.block(
|
.block(pane_block(true).title(plain_title("Downloads", true)))
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(true))
|
|
||||||
.border_type(border_type(true))
|
|
||||||
.title("Downloads"),
|
|
||||||
)
|
|
||||||
.column_spacing(1)
|
.column_spacing(1)
|
||||||
.row_highlight_style(highlight_style())
|
.row_highlight_style(highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
@@ -314,12 +293,8 @@ impl MultiDownloadProgress {
|
|||||||
("Esc/q", "cancel all"),
|
("Esc/q", "cancel all"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let controls_widget = Paragraph::new(controls_line).block(
|
let controls_widget = Paragraph::new(controls_line)
|
||||||
Block::default()
|
.block(pane_block(false).title(plain_title("Controls", false)));
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title("Controls"),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(controls_widget, area);
|
frame.render_widget(controls_widget, area);
|
||||||
}
|
}
|
||||||
@@ -378,10 +353,7 @@ pub fn render_download_stats(
|
|||||||
.sum();
|
.sum();
|
||||||
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
|
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
|
||||||
|
|
||||||
let block = Block::default()
|
let block = pane_block(false).title(plain_title("Status", false));
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title("Status");
|
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
let inner_area = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
@@ -393,52 +365,23 @@ pub fn render_download_stats(
|
|||||||
])
|
])
|
||||||
.split(inner_area);
|
.split(inner_area);
|
||||||
|
|
||||||
// Left container: Statistics with styled values
|
let count = |value: usize, style: Style| {
|
||||||
|
Span::styled(value.to_string(), style.add_modifier(Modifier::BOLD))
|
||||||
|
};
|
||||||
let stats_line = Line::from(vec![
|
let stats_line = Line::from(vec![
|
||||||
Span::raw("soulseek-rs 🦀 "),
|
Span::styled("soulseek-rs", primary_style()),
|
||||||
Span::styled(
|
Span::styled(format!(" 🦀 v{VERSION}"), dimmed_style()),
|
||||||
format!("v{VERSION} "),
|
Span::styled(" Downloads: ", dimmed_style()),
|
||||||
Style::default()
|
count(active_count, accent_style()),
|
||||||
.fg(COLOR_PRIMARY)
|
Span::styled(" active, ", dimmed_style()),
|
||||||
.add_modifier(Modifier::BOLD),
|
count(completed, success_style()),
|
||||||
),
|
Span::styled(" completed, ", dimmed_style()),
|
||||||
Span::raw(" Downloads: "),
|
count(failed, error_style()),
|
||||||
Span::styled(
|
Span::styled(" failed, ", dimmed_style()),
|
||||||
active_count.to_string(),
|
count(queued, warning_style()),
|
||||||
Style::default()
|
Span::styled(" queued, ", dimmed_style()),
|
||||||
.fg(COLOR_PRIMARY)
|
count(paused, info_style()),
|
||||||
.add_modifier(Modifier::BOLD),
|
Span::styled(" paused", dimmed_style()),
|
||||||
),
|
|
||||||
Span::raw(" active, "),
|
|
||||||
Span::styled(
|
|
||||||
completed.to_string(),
|
|
||||||
Style::default()
|
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" completed, "),
|
|
||||||
Span::styled(
|
|
||||||
failed.to_string(),
|
|
||||||
Style::default()
|
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" failed, "),
|
|
||||||
Span::styled(
|
|
||||||
queued.to_string(),
|
|
||||||
Style::default()
|
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" queued"),
|
|
||||||
Span::raw(", "),
|
|
||||||
Span::styled(
|
|
||||||
paused.to_string(),
|
|
||||||
Style::default()
|
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw(" paused"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let stats_paragraph = Paragraph::new(stats_line);
|
let stats_paragraph = Paragraph::new(stats_line);
|
||||||
@@ -453,15 +396,13 @@ pub fn render_download_stats(
|
|||||||
let mut spans: Vec<Span> = Vec::new();
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
|
||||||
spans.extend(data_str.spans);
|
spans.extend(data_str.spans);
|
||||||
spans.push(Span::raw(" • "));
|
spans.push(Span::styled(" · ", dimmed_style()));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("{speed_mb}"),
|
format!("{speed_mb}"),
|
||||||
Style::default()
|
warning_style().add_modifier(Modifier::BOLD),
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
));
|
));
|
||||||
spans.push(Span::raw(" MB/s"));
|
spans.push(Span::styled(" MB/s", dimmed_style()));
|
||||||
spans.push(Span::raw(" • "));
|
spans.push(Span::styled(" · ", dimmed_style()));
|
||||||
spans.extend(progress_bar.spans);
|
spans.extend(progress_bar.spans);
|
||||||
|
|
||||||
let progress_line = Line::from(spans);
|
let progress_line = Line::from(spans);
|
||||||
|
|||||||
+21
-14
@@ -5,14 +5,18 @@
|
|||||||
//! machine so it can be tested without a terminal; the IO loop
|
//! machine so it can be tested without a terminal; the IO loop
|
||||||
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
||||||
|
|
||||||
|
use crate::ui::{
|
||||||
|
GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style,
|
||||||
|
pane_block, primary_style, title_style, warning_style,
|
||||||
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
||||||
layout::{Constraint, Flex, Layout, Rect},
|
layout::{Constraint, Flex, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::Modifier,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Clear, Paragraph},
|
||||||
};
|
};
|
||||||
use soulseek_rs::{Client, ClientSettings};
|
use soulseek_rs::{Client, ClientSettings};
|
||||||
use std::sync::mpsc::{Receiver, channel};
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
@@ -231,10 +235,10 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
let area = centered(frame.area(), 52, 12);
|
let area = centered(frame.area(), 52, 12);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let block = Block::default()
|
let block = pane_block(true).title(Line::from(vec![
|
||||||
.borders(Borders::ALL)
|
Span::styled(" soulseek-rs", primary_style()),
|
||||||
.title(" Soulseek Login ")
|
Span::styled(" login ", title_style(true)),
|
||||||
.title_style(Style::default().add_modifier(Modifier::BOLD));
|
]));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -249,16 +253,19 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
let field = |label: &str, value: String, focused: bool| {
|
let field = |label: &str, value: String, focused: bool| {
|
||||||
let marker = if focused { "> " } else { " " };
|
let marker = if focused { "› " } else { " " };
|
||||||
let style = if focused {
|
let style = if focused {
|
||||||
Style::default().add_modifier(Modifier::BOLD)
|
info_style().add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
dimmed_style()
|
||||||
};
|
};
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::styled(format!("{marker}{label:<10}"), style),
|
Span::styled(format!("{marker}{label:<10}"), style),
|
||||||
Span::raw(value),
|
Span::styled(value, primary_style()),
|
||||||
Span::raw(if focused { "▏" } else { "" }),
|
Span::styled(
|
||||||
|
if focused { GLYPH_CURSOR } else { "" },
|
||||||
|
accent_style(),
|
||||||
|
),
|
||||||
]))
|
]))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,10 +291,10 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
LoginPhase::Editing => Paragraph::new(""),
|
LoginPhase::Editing => Paragraph::new(""),
|
||||||
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
||||||
"Connecting…",
|
"Connecting…",
|
||||||
Style::default().fg(Color::Yellow),
|
warning_style(),
|
||||||
))),
|
))),
|
||||||
LoginPhase::Failed(message) => Paragraph::new(Line::from(
|
LoginPhase::Failed(message) => Paragraph::new(Line::from(
|
||||||
Span::styled(message.clone(), Style::default().fg(Color::Red)),
|
Span::styled(message.clone(), error_style()),
|
||||||
))
|
))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: true }),
|
.wrap(ratatui::widgets::Wrap { trim: true }),
|
||||||
};
|
};
|
||||||
@@ -298,7 +305,7 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
"New usernames are registered automatically.\n\
|
"New usernames are registered automatically.\n\
|
||||||
Tab: switch · Enter: log in · Esc: quit",
|
Tab: switch · Enter: log in · Esc: quit",
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::DarkGray)),
|
.style(dimmed_style()),
|
||||||
rows[5],
|
rows[5],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ use crate::ui::panes::{
|
|||||||
render_searches_pane,
|
render_searches_pane,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
accent_style, format_shortcuts_styled, pane_block, plain_title,
|
||||||
|
primary_style, render_download_stats,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Position, Rect},
|
layout::{Constraint, Layout, Position, Rect},
|
||||||
widgets::{Block, Borders, Paragraph},
|
text::{Line, Span},
|
||||||
|
widgets::Paragraph,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMMAND_BAR_PREFIX: &str = "search: ";
|
const COMMAND_BAR_PREFIX: &str = "search: ";
|
||||||
@@ -239,9 +241,7 @@ impl MainTui {
|
|||||||
lines.push(ratatui::text::Line::from(status.clone()));
|
lines.push(ratatui::text::Line::from(status.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let block = ratatui::widgets::Block::default()
|
let block = pane_block(true).title(plain_title("Settings", true));
|
||||||
.borders(ratatui::widgets::Borders::ALL)
|
|
||||||
.title(" Settings ");
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
ratatui::widgets::Paragraph::new(lines)
|
ratatui::widgets::Paragraph::new(lines)
|
||||||
.block(block)
|
.block(block)
|
||||||
@@ -275,13 +275,11 @@ impl MainTui {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let popup = Paragraph::new(lines).block(
|
let popup =
|
||||||
Block::default()
|
Paragraph::new(lines).block(pane_block(true).title(plain_title(
|
||||||
.borders(Borders::ALL)
|
"Messages · m: compose · i/Esc: close",
|
||||||
.border_style(border_style(true))
|
true,
|
||||||
.border_type(border_type(true))
|
)));
|
||||||
.title(" Messages (m: compose, i/Esc: close) "),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
frame.render_widget(popup, area);
|
frame.render_widget(popup, area);
|
||||||
@@ -436,45 +434,39 @@ impl MainTui {
|
|||||||
more => format!("{} folders", more.len()),
|
more => format!("{} folders", more.len()),
|
||||||
};
|
};
|
||||||
let title = format!("Shortcuts · Sharing: {sharing}");
|
let title = format!("Shortcuts · Sharing: {sharing}");
|
||||||
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
|
let shortcuts_widget = Paragraph::new(shortcuts_line)
|
||||||
Block::default()
|
.block(pane_block(false).title(plain_title(title, false)));
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title(title),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(shortcuts_widget, area);
|
frame.render_widget(shortcuts_widget, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let prefix = command_bar_prefix(self.state.command_bar_mode);
|
let prefix = command_bar_prefix(self.state.command_bar_mode);
|
||||||
let content_width = area.width.saturating_sub(2);
|
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 prefix_width = prefix.chars().count() as u16;
|
||||||
let input_width = content_width.saturating_sub(prefix_width);
|
let input_width = inner.width.saturating_sub(prefix_width);
|
||||||
let (visible_input, cursor_column) = visible_input_at_cursor(
|
let (visible_input, cursor_column) = visible_input_at_cursor(
|
||||||
&self.state.command_bar_input,
|
&self.state.command_bar_input,
|
||||||
self.state.command_bar_cursor_position,
|
self.state.command_bar_cursor_position,
|
||||||
input_width,
|
input_width,
|
||||||
);
|
);
|
||||||
let command_text = format!("{prefix}{visible_input}");
|
let command_line = Line::from(vec![
|
||||||
|
Span::styled(prefix, accent_style()),
|
||||||
|
Span::styled(visible_input, primary_style()),
|
||||||
|
]);
|
||||||
|
|
||||||
let paragraph = Paragraph::new(command_text).block(
|
frame.render_widget(Paragraph::new(command_line).block(block), area);
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(true))
|
|
||||||
.border_type(border_type(true)),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
if inner.width > 0 && inner.height > 0 {
|
||||||
|
let cursor_x = inner
|
||||||
if area.width > 2 && area.height > 2 {
|
|
||||||
let cursor_x = area
|
|
||||||
.x
|
.x
|
||||||
.saturating_add(1)
|
|
||||||
.saturating_add(prefix_width)
|
.saturating_add(prefix_width)
|
||||||
.saturating_add(cursor_column)
|
.saturating_add(cursor_column)
|
||||||
.min(area.x.saturating_add(area.width.saturating_sub(2)));
|
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
|
||||||
frame.set_cursor_position(Position::new(cursor_x, area.y + 1));
|
frame.set_cursor_position(Position::new(cursor_x, inner.y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, border_style, border_type, dimmed_style, error_style,
|
HIGHLIGHT_SYMBOL, dimmed_style, error_style, format_bytes,
|
||||||
format_bytes, get_spinner_char, highlight_style, primary_style,
|
get_spinner_char, highlight_style, pane_block, primary_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::Modifier,
|
style::Modifier,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
|
||||||
TableState,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Render the browse popup: a tab bar of browsed users (when more than one is
|
/// Render the browse popup: a tab bar of browsed users (when more than one is
|
||||||
@@ -70,11 +67,7 @@ fn render_browse_one(
|
|||||||
),
|
),
|
||||||
_ => format!(" Browse {} ", browse.username),
|
_ => format!(" Browse {} ", browse.username),
|
||||||
};
|
};
|
||||||
let block = Block::default()
|
let block = pane_block(true).title(title);
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(true))
|
|
||||||
.border_type(border_type(true))
|
|
||||||
.title(title);
|
|
||||||
|
|
||||||
match browse.status {
|
match browse.status {
|
||||||
BrowseStatus::Loading => {
|
BrowseStatus::Loading => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::DownloadEntry;
|
use crate::models::DownloadEntry;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
border_style, border_type, dimmed_style, error_style, format_bytes,
|
accent_style, dimmed_style, error_style, format_bytes, format_progress_bar,
|
||||||
format_progress_bar, format_speed, inactive_style, info_style,
|
format_speed, inactive_style, info_style, pane_block, pane_title,
|
||||||
primary_style, success_style, warning_style,
|
primary_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -9,7 +9,7 @@ use ratatui::{
|
|||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
use soulseek_rs::utils::path::expand_tilde;
|
use soulseek_rs::utils::path::expand_tilde;
|
||||||
@@ -22,11 +22,7 @@ pub fn render_download_info_pane(
|
|||||||
selected: Option<&DownloadEntry>,
|
selected: Option<&DownloadEntry>,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
) {
|
) {
|
||||||
let block = Block::default()
|
let block = pane_block(focused).title(pane_title("Info", "", focused));
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(focused))
|
|
||||||
.border_type(border_type(focused))
|
|
||||||
.title("[Info]");
|
|
||||||
|
|
||||||
let Some(entry) = selected else {
|
let Some(entry) = selected else {
|
||||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||||
@@ -55,15 +51,23 @@ fn build_info_lines(
|
|||||||
|
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
basename,
|
basename,
|
||||||
primary_style().add_modifier(ratatui::style::Modifier::BOLD),
|
accent_style().add_modifier(ratatui::style::Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
if !parent_dir.is_empty() {
|
if !parent_dir.is_empty() {
|
||||||
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
|
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
|
||||||
}
|
}
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
lines.push(label_value("User", &download.username));
|
lines.push(label_value_styled(
|
||||||
lines.push(label_value("Size", &format_bytes(download.size)));
|
"User",
|
||||||
|
download.username.clone(),
|
||||||
|
info_style(),
|
||||||
|
));
|
||||||
|
lines.push(label_value_styled(
|
||||||
|
"Size",
|
||||||
|
format_bytes(download.size),
|
||||||
|
warning_style(),
|
||||||
|
));
|
||||||
|
|
||||||
let (status_text, status_style) = match &download.status {
|
let (status_text, status_style) = match &download.status {
|
||||||
DownloadStatus::Queued => ("Queued".to_string(), inactive_style()),
|
DownloadStatus::Queued => ("Queued".to_string(), inactive_style()),
|
||||||
@@ -80,7 +84,7 @@ fn build_info_lines(
|
|||||||
let save_path = expand_tilde(&download.download_directory)
|
let save_path = expand_tilde(&download.download_directory)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
lines.push(label_value("Save to", &save_path));
|
lines.push(label_value_styled("Save to", save_path, info_style()));
|
||||||
|
|
||||||
if let Some(bitrate) = download.metadata.bitrate {
|
if let Some(bitrate) = download.metadata.bitrate {
|
||||||
lines.push(label_value("Bitrate", &format!("{bitrate} kbps")));
|
lines.push(label_value("Bitrate", &format!("{bitrate} kbps")));
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use crate::models::DownloadEntry;
|
use crate::models::DownloadEntry;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes,
|
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, HIGHLIGHT_SYMBOL, accent_style,
|
||||||
|
body_style, dimmed_style, download_status_glyph, error_style, format_bytes,
|
||||||
format_speed, header_style, highlight_style, inactive_style, info_style,
|
format_speed, header_style, highlight_style, inactive_style, info_style,
|
||||||
success_style, warning_style,
|
pane_block, pane_title, primary_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
widgets::{
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
|
||||||
TableState,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
||||||
@@ -24,15 +22,16 @@ pub fn render_downloads_pane(
|
|||||||
focused: bool,
|
focused: bool,
|
||||||
) {
|
) {
|
||||||
if downloads.is_empty() && uploads.is_empty() {
|
if downloads.is_empty() && uploads.is_empty() {
|
||||||
let empty_block = Block::default()
|
let empty_block = pane_block(focused).title(pane_title(
|
||||||
.borders(Borders::ALL)
|
"3",
|
||||||
.border_style(border_style(focused))
|
"Downloads/Uploads",
|
||||||
.border_type(border_type(focused))
|
focused,
|
||||||
.title("[3] Downloads/Uploads");
|
));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(
|
let paragraph = Paragraph::new(
|
||||||
"No transfers. Select files from Results and press Enter.",
|
"No transfers. Select files in [2] Results and press Enter.",
|
||||||
)
|
)
|
||||||
|
.style(dimmed_style())
|
||||||
.block(empty_block);
|
.block(empty_block);
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
return;
|
return;
|
||||||
@@ -51,14 +50,8 @@ pub fn render_downloads_pane(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|download_entry| {
|
.map(|download_entry| {
|
||||||
let download = &download_entry.download;
|
let download = &download_entry.download;
|
||||||
let (status_icon, status_style) = match &download.status {
|
let (status_icon, status_style) =
|
||||||
DownloadStatus::Queued => ("⋯", inactive_style()),
|
download_status_glyph(&download.status);
|
||||||
DownloadStatus::InProgress { .. } => ("⧗", warning_style()),
|
|
||||||
DownloadStatus::Paused { .. } => ("⏸", info_style()),
|
|
||||||
DownloadStatus::Completed => ("✓", success_style()),
|
|
||||||
DownloadStatus::Failed(_) => ("✗", error_style()),
|
|
||||||
DownloadStatus::TimedOut => ("⏱", error_style()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let progress_text = match &download.status {
|
let progress_text = match &download.status {
|
||||||
DownloadStatus::Queued => "Queued".to_string(),
|
DownloadStatus::Queued => "Queued".to_string(),
|
||||||
@@ -104,22 +97,31 @@ pub fn render_downloads_pane(
|
|||||||
_ => "-".to_string(),
|
_ => "-".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let progress_style = match &download.status {
|
||||||
|
DownloadStatus::Completed => success_style(),
|
||||||
|
DownloadStatus::Failed(_) | DownloadStatus::TimedOut => {
|
||||||
|
error_style()
|
||||||
|
}
|
||||||
|
DownloadStatus::Queued => inactive_style(),
|
||||||
|
_ => primary_style(),
|
||||||
|
};
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(status_icon).style(status_style),
|
Cell::from(status_icon).style(status_style),
|
||||||
Cell::from(download.filename.clone()),
|
Cell::from(download.filename.clone()).style(body_style()),
|
||||||
Cell::from(download.username.clone()),
|
Cell::from(download.username.clone()).style(info_style()),
|
||||||
Cell::from(progress_text),
|
Cell::from(progress_text).style(progress_style),
|
||||||
Cell::from(speed_text),
|
Cell::from(speed_text).style(warning_style()),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
rows.extend(uploads.iter().map(|upload| {
|
rows.extend(uploads.iter().map(|upload| {
|
||||||
let (status_icon, status_style) = match &upload.status {
|
let (status_icon, status_style) = match &upload.status {
|
||||||
UploadStatus::InProgress => ("⧗", warning_style()),
|
UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()),
|
||||||
UploadStatus::Completed => ("✓", success_style()),
|
UploadStatus::Completed => (GLYPH_DONE, success_style()),
|
||||||
UploadStatus::Cancelled => ("✗", inactive_style()),
|
UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()),
|
||||||
UploadStatus::Failed(_) => ("✗", error_style()),
|
UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()),
|
||||||
};
|
};
|
||||||
let progress_text = match &upload.status {
|
let progress_text = match &upload.status {
|
||||||
UploadStatus::InProgress => {
|
UploadStatus::InProgress => {
|
||||||
@@ -147,9 +149,9 @@ pub fn render_downloads_pane(
|
|||||||
.unwrap_or(&upload.filename);
|
.unwrap_or(&upload.filename);
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(format!("↑ {status_icon}")).style(status_style),
|
Cell::from(format!("↑ {status_icon}")).style(status_style),
|
||||||
Cell::from(basename.to_string()),
|
Cell::from(basename.to_string()).style(body_style()),
|
||||||
Cell::from(upload.username.clone()),
|
Cell::from(upload.username.clone()).style(info_style()),
|
||||||
Cell::from(progress_text),
|
Cell::from(progress_text).style(primary_style()),
|
||||||
Cell::from(String::new()),
|
Cell::from(String::new()),
|
||||||
])
|
])
|
||||||
}));
|
}));
|
||||||
@@ -167,13 +169,11 @@ pub fn render_downloads_pane(
|
|||||||
.row_highlight_style(highlight_style())
|
.row_highlight_style(highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(
|
.block(pane_block(focused).title(pane_title(
|
||||||
Block::default()
|
"3",
|
||||||
.borders(Borders::ALL)
|
"Downloads/Uploads",
|
||||||
.border_style(border_style(focused))
|
focused,
|
||||||
.border_type(border_type(focused))
|
)));
|
||||||
.title("[3] Downloads/Uploads"),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_stateful_widget(table, area, table_state);
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
use crate::models::FileDisplayData;
|
use crate::models::FileDisplayData;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, border_style, border_type, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||||
header_style, highlight_style,
|
header_style, highlight_style, info_style, pane_block, pane_title,
|
||||||
|
success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
widgets::{
|
text::{Line, Span},
|
||||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
TableState,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -62,26 +61,32 @@ pub fn render_results_pane(
|
|||||||
active_search_query,
|
active_search_query,
|
||||||
} = params;
|
} = params;
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
let title = if let Some(query) = active_search_query {
|
let title = match active_search_query {
|
||||||
format!("[2] Results: {query}")
|
Some(query) => format!("Results: {query}"),
|
||||||
} else {
|
None => "Results".to_string(),
|
||||||
"[2] Results".to_string()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let empty_block = Block::default()
|
let empty_block =
|
||||||
.borders(Borders::ALL)
|
pane_block(focused).title(pane_title("2", &title, focused));
|
||||||
.border_style(border_style(focused))
|
|
||||||
.border_type(border_type(focused))
|
|
||||||
.title(title);
|
|
||||||
|
|
||||||
let message = if is_filtering {
|
let message = if is_filtering {
|
||||||
format!("No results match filter: '{filter_query}'")
|
vec![Line::from(Span::styled(
|
||||||
|
format!("No results match filter: '{filter_query}'"),
|
||||||
|
dimmed_style(),
|
||||||
|
))]
|
||||||
} else {
|
} else {
|
||||||
format!(
|
vec![
|
||||||
"soulseek-rs 🦀 v{VERSION}
|
Line::from(vec![
|
||||||
Michel de Graaf 2026\n
|
Span::styled("soulseek-rs", body_style()),
|
||||||
No results. Select a search from the Searches pane [1]. Or start new search [s → search] \n"
|
Span::styled(format!(" v{VERSION}"), dimmed_style()),
|
||||||
)
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled("No results yet.", body_style())),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Pick a search in [1] Searches, or start a new one.",
|
||||||
|
dimmed_style(),
|
||||||
|
)),
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
let paragraph = Paragraph::new(message).block(empty_block);
|
let paragraph = Paragraph::new(message).block(empty_block);
|
||||||
@@ -124,14 +129,20 @@ No results. Select a search from the Searches pane [1]. Or start new search [s
|
|||||||
"-".to_string()
|
"-".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let checkbox_style = if checkbox == "[✓]" {
|
||||||
|
success_style()
|
||||||
|
} else {
|
||||||
|
dimmed_style()
|
||||||
|
};
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(checkbox),
|
Cell::from(checkbox).style(checkbox_style),
|
||||||
Cell::from(file.filename.clone()),
|
Cell::from(file.filename.clone()).style(body_style()),
|
||||||
Cell::from(format_bytes(file.size)),
|
Cell::from(format_bytes(file.size)).style(warning_style()),
|
||||||
Cell::from(file.username.clone()),
|
Cell::from(file.username.clone()).style(info_style()),
|
||||||
Cell::from(bitrate_str),
|
Cell::from(bitrate_str).style(dimmed_style()),
|
||||||
Cell::from(speed_str),
|
Cell::from(speed_str).style(dimmed_style()),
|
||||||
Cell::from(file.slots.to_string()),
|
Cell::from(file.slots.to_string()).style(dimmed_style()),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -147,11 +158,11 @@ No results. Select a search from the Searches pane [1]. Or start new search [s
|
|||||||
];
|
];
|
||||||
|
|
||||||
let title = if is_filtering {
|
let title = if is_filtering {
|
||||||
format!("[2] Results - Filter: '{filter_query}'")
|
format!("Results · filter: '{filter_query}'")
|
||||||
} else if let Some(query) = active_search_query {
|
} else if let Some(query) = active_search_query {
|
||||||
format!("[2] Results: {query}")
|
format!("Results: {query}")
|
||||||
} else {
|
} else {
|
||||||
"[2] Results".to_string()
|
"Results".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let table = Table::new(rows, widths)
|
let table = Table::new(rows, widths)
|
||||||
@@ -159,13 +170,7 @@ No results. Select a search from the Searches pane [1]. Or start new search [s
|
|||||||
.row_highlight_style(highlight_style())
|
.row_highlight_style(highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(
|
.block(pane_block(focused).title(pane_title("2", &title, focused)));
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(focused))
|
|
||||||
.border_type(border_type(focused))
|
|
||||||
.title(title),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_stateful_widget(table, area, table_state);
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::{RoomsState, RoomsView};
|
use crate::models::{RoomsState, RoomsView};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, accent_style, border_style, border_type, dimmed_style,
|
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
||||||
highlight_style, info_style, primary_style,
|
highlight_style, info_style, pane_block, plain_title, primary_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -43,11 +43,7 @@ fn render_list(
|
|||||||
" Rooms (Enter: join, /: filter, Tab: open rooms, Esc: close) "
|
" Rooms (Enter: join, /: filter, Tab: open rooms, Esc: close) "
|
||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
let block = Block::default()
|
let block = pane_block(true).title(title);
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(true))
|
|
||||||
.border_type(border_type(true))
|
|
||||||
.title(title);
|
|
||||||
|
|
||||||
let filtered = rooms.filtered_rooms();
|
let filtered = rooms.filtered_rooms();
|
||||||
if filtered.is_empty() {
|
if filtered.is_empty() {
|
||||||
@@ -98,13 +94,9 @@ fn render_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
||||||
let block = Block::default()
|
let block = pane_block(true).title(
|
||||||
.borders(Borders::ALL)
|
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
|
||||||
.border_style(border_style(true))
|
);
|
||||||
.border_type(border_type(true))
|
|
||||||
.title(
|
|
||||||
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
|
|
||||||
);
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -209,7 +201,8 @@ fn render_users(
|
|||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::LEFT)
|
.borders(Borders::LEFT)
|
||||||
.border_style(dimmed_style())
|
.border_style(dimmed_style())
|
||||||
.title(format!(" Users ({}) ", users.len()));
|
.padding(PANE_PADDING)
|
||||||
|
.title(plain_title(format!("Users ({})", users.len()), false));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use crate::models::{SearchEntry, SearchStatus};
|
use crate::models::{SearchEntry, SearchStatus};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, border_style, border_type, header_style, highlight_style,
|
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, highlight_style,
|
||||||
success_style, warning_style,
|
pane_block, pane_title, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
widgets::{Block, Borders, Cell, HighlightSpacing, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn render_searches_pane(
|
pub fn render_searches_pane(
|
||||||
@@ -46,8 +46,8 @@ pub fn render_searches_pane(
|
|||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
status_cell,
|
status_cell,
|
||||||
Cell::from(search.query.clone()),
|
Cell::from(search.query.clone()).style(body_style()),
|
||||||
Cell::from(results_text),
|
Cell::from(results_text).style(dimmed_style()),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -63,13 +63,7 @@ pub fn render_searches_pane(
|
|||||||
.row_highlight_style(highlight_style())
|
.row_highlight_style(highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(
|
.block(pane_block(focused).title(pane_title("1", "Searches", focused)));
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(border_style(focused))
|
|
||||||
.border_type(border_type(focused))
|
|
||||||
.title("[1] Searches"),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_stateful_widget(table, area, table_state);
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-63
@@ -2,32 +2,52 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::symbols;
|
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::BorderType;
|
use ratatui::widgets::{Block, BorderType, Borders, Padding};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
|
||||||
// Color scheme - using Light variants to match openapi-tui
|
/// One column of breathing room on each side, so content never touches the
|
||||||
pub const COLOR_PRIMARY: Color = Color::LightCyan;
|
/// border. Vertical rows stay unpadded — they are too scarce in a terminal.
|
||||||
pub const COLOR_SUCCESS: Color = Color::LightGreen;
|
pub const PANE_PADDING: Padding = Padding::horizontal(1);
|
||||||
pub const COLOR_WARNING: Color = Color::LightYellow;
|
|
||||||
pub const COLOR_ERROR: Color = Color::LightRed;
|
|
||||||
pub const COLOR_INACTIVE: Color = Color::Gray;
|
|
||||||
pub const COLOR_INFO: Color = Color::LightBlue;
|
|
||||||
pub const COLOR_ACCENT: Color = Color::LightMagenta;
|
|
||||||
|
|
||||||
// Border colors
|
pub const VINYL_2: Color = Color::Rgb(0x26, 0x21, 0x1D);
|
||||||
pub const COLOR_FOCUSED_BORDER: Color = Color::LightGreen;
|
pub const DUST: Color = Color::Rgb(0x8A, 0x81, 0x78);
|
||||||
|
pub const PAPER: Color = Color::Rgb(0xE8, 0xE1, 0xD6);
|
||||||
|
pub const PAPER_DIM: Color = Color::Rgb(0xC9, 0xC1, 0xB4);
|
||||||
|
pub const OXIDE: Color = Color::Rgb(0xC1, 0x50, 0x2E);
|
||||||
|
pub const TAPE: Color = Color::Rgb(0xD8, 0xA6, 0x57);
|
||||||
|
pub const PHOSPHOR: Color = Color::Rgb(0x7F, 0xB6, 0x85);
|
||||||
|
pub const SIGNAL: Color = Color::Rgb(0x6E, 0x9D, 0xC9);
|
||||||
|
pub const ALARM: Color = Color::Rgb(0xB3, 0x3A, 0x3A);
|
||||||
|
|
||||||
// Highlight symbol - using arrow like openapi-tui
|
pub const COLOR_PRIMARY: Color = PAPER;
|
||||||
pub const HIGHLIGHT_SYMBOL: &str = symbols::scrollbar::HORIZONTAL.end;
|
pub const COLOR_ACCENT: Color = OXIDE;
|
||||||
|
pub const COLOR_SUCCESS: Color = PHOSPHOR;
|
||||||
|
pub const COLOR_WARNING: Color = TAPE;
|
||||||
|
pub const COLOR_ERROR: Color = ALARM;
|
||||||
|
pub const COLOR_INFO: Color = SIGNAL;
|
||||||
|
pub const COLOR_INACTIVE: Color = DUST;
|
||||||
|
pub const COLOR_FOCUSED_BORDER: Color = PHOSPHOR;
|
||||||
|
|
||||||
|
pub const GLYPH_QUEUED: &str = "⋯";
|
||||||
|
pub const GLYPH_ACTIVE: &str = "↯";
|
||||||
|
pub const GLYPH_PAUSED: &str = "⏸";
|
||||||
|
pub const GLYPH_DONE: &str = "✓";
|
||||||
|
pub const GLYPH_FAILED: &str = "✗";
|
||||||
|
pub const GLYPH_TIMED_OUT: &str = "⧗";
|
||||||
|
pub const GLYPH_CURSOR: &str = "▮";
|
||||||
|
pub const HIGHLIGHT_SYMBOL: &str = "›";
|
||||||
|
pub const SHORTCUT_ARROW: &str = "→";
|
||||||
|
|
||||||
// Reusable styles
|
|
||||||
pub fn header_style() -> Style {
|
pub fn header_style() -> Style {
|
||||||
Style::default().add_modifier(Modifier::BOLD)
|
Style::default().fg(DUST).add_modifier(Modifier::BOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn highlight_style() -> Style {
|
pub fn highlight_style() -> Style {
|
||||||
Style::default().add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.bg(VINYL_2)
|
||||||
|
.fg(PAPER)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn success_style() -> Style {
|
pub fn success_style() -> Style {
|
||||||
@@ -59,16 +79,19 @@ pub fn accent_style() -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn dimmed_style() -> Style {
|
pub fn dimmed_style() -> Style {
|
||||||
Style::default().add_modifier(Modifier::DIM)
|
Style::default().fg(DUST)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body_style() -> Style {
|
||||||
|
Style::default().fg(PAPER_DIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus-based border styling (matching openapi-tui)
|
|
||||||
pub fn focused_border_style() -> Style {
|
pub fn focused_border_style() -> Style {
|
||||||
Style::default().fg(COLOR_FOCUSED_BORDER)
|
Style::default().fg(COLOR_FOCUSED_BORDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unfocused_border_style() -> Style {
|
pub fn unfocused_border_style() -> Style {
|
||||||
Style::default()
|
Style::default().fg(DUST)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn focused_border_type() -> BorderType {
|
pub const fn focused_border_type() -> BorderType {
|
||||||
@@ -79,7 +102,6 @@ pub const fn unfocused_border_type() -> BorderType {
|
|||||||
BorderType::Rounded
|
BorderType::Rounded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get border style based on focus state
|
|
||||||
pub fn border_style(focused: bool) -> Style {
|
pub fn border_style(focused: bool) -> Style {
|
||||||
if focused {
|
if focused {
|
||||||
focused_border_style()
|
focused_border_style()
|
||||||
@@ -96,16 +118,53 @@ pub const fn border_type(focused: bool) -> BorderType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcut formatting helper (matching openapi-tui format)
|
pub fn title_style(focused: bool) -> Style {
|
||||||
pub fn format_shortcuts(shortcuts: &[(&str, &str)]) -> String {
|
if focused {
|
||||||
shortcuts
|
Style::default().fg(PHOSPHOR).add_modifier(Modifier::BOLD)
|
||||||
.iter()
|
} else {
|
||||||
.map(|(key, action)| format!("[{key} {HIGHLIGHT_SYMBOL} {action}]"))
|
Style::default().fg(DUST)
|
||||||
.collect::<Vec<_>>()
|
}
|
||||||
.join(" ")
|
}
|
||||||
|
|
||||||
|
pub fn pane_title(tag: &str, title: &str, focused: bool) -> Line<'static> {
|
||||||
|
let label = if title.is_empty() {
|
||||||
|
" ".to_string()
|
||||||
|
} else {
|
||||||
|
format!(" {title} ")
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!(" [{tag}]"), Style::default().fg(SIGNAL)),
|
||||||
|
Span::styled(label, title_style(focused)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plain_title(title: impl Into<String>, focused: bool) -> Line<'static> {
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" {} ", title.into()),
|
||||||
|
title_style(focused),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard bordered pane. Chain `.title(..)` for a legend.
|
||||||
|
pub fn pane_block(focused: bool) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.padding(PANE_PADDING)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) {
|
||||||
|
match status {
|
||||||
|
DownloadStatus::Queued => (GLYPH_QUEUED, warning_style()),
|
||||||
|
DownloadStatus::InProgress { .. } => (GLYPH_ACTIVE, accent_style()),
|
||||||
|
DownloadStatus::Paused { .. } => (GLYPH_PAUSED, info_style()),
|
||||||
|
DownloadStatus::Completed => (GLYPH_DONE, success_style()),
|
||||||
|
DownloadStatus::Failed(_) => (GLYPH_FAILED, error_style()),
|
||||||
|
DownloadStatus::TimedOut => (GLYPH_TIMED_OUT, error_style()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styled shortcut formatting helper (returns Line with colored Spans)
|
|
||||||
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
|
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
@@ -115,36 +174,15 @@ pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spans.push(Span::styled("[", dimmed_style()));
|
spans.push(Span::styled("[", dimmed_style()));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL)));
|
||||||
key.to_string(),
|
spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()));
|
||||||
Style::default()
|
spans.push(Span::styled(action.to_string(), body_style()));
|
||||||
.fg(COLOR_PRIMARY)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
));
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" {HIGHLIGHT_SYMBOL} "),
|
|
||||||
dimmed_style(),
|
|
||||||
));
|
|
||||||
spans.push(Span::raw(action.to_string()));
|
|
||||||
spans.push(Span::styled("]", dimmed_style()));
|
spans.push(Span::styled("]", dimmed_style()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status colors for downloads
|
|
||||||
pub fn status_color(status: &str) -> Color {
|
|
||||||
match status {
|
|
||||||
"queued" => COLOR_INACTIVE,
|
|
||||||
"in_progress" => COLOR_WARNING,
|
|
||||||
"paused" => COLOR_INFO,
|
|
||||||
"completed" => COLOR_PRIMARY,
|
|
||||||
"failed" | "timed_out" => COLOR_ERROR,
|
|
||||||
_ => Color::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format progress bar with styled filled/unfilled portions
|
|
||||||
pub fn format_progress_bar(
|
pub fn format_progress_bar(
|
||||||
progress: f64,
|
progress: f64,
|
||||||
width: usize,
|
width: usize,
|
||||||
@@ -153,22 +191,72 @@ pub fn format_progress_bar(
|
|||||||
let filled = (progress * width as f64) as usize;
|
let filled = (progress * width as f64) as usize;
|
||||||
let empty = width.saturating_sub(filled);
|
let empty = width.saturating_sub(filled);
|
||||||
|
|
||||||
let filled_part = "█".repeat(filled);
|
|
||||||
let empty_part = "░".repeat(empty);
|
|
||||||
|
|
||||||
let spans = vec![
|
let spans = vec![
|
||||||
Span::raw("["),
|
Span::styled("[", dimmed_style()),
|
||||||
Span::styled(filled_part, primary_style()),
|
Span::styled("█".repeat(filled), Style::default().fg(SIGNAL)),
|
||||||
Span::styled(empty_part, dimmed_style()),
|
Span::styled("░".repeat(empty), Style::default().fg(VINYL_2)),
|
||||||
Span::raw("]"),
|
Span::styled("]", dimmed_style()),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{percentage}%"),
|
format!("{percentage}%"),
|
||||||
primary_style()
|
primary_style().add_modifier(Modifier::BOLD),
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
.fg(Color::default()),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn colors(line: &Line<'static>) -> Vec<Option<Color>> {
|
||||||
|
line.spans.iter().map(|s| s.style.fg).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_title_tags_in_signal_and_tracks_focus() {
|
||||||
|
assert_eq!(
|
||||||
|
colors(&pane_title("2", "Results", true)),
|
||||||
|
vec![Some(SIGNAL), Some(PHOSPHOR)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
colors(&pane_title("2", "Results", false)),
|
||||||
|
vec![Some(SIGNAL), Some(DUST)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn titles_are_inset_from_the_border() {
|
||||||
|
let inset = |line: &Line<'static>| {
|
||||||
|
let text: String =
|
||||||
|
line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||||
|
text.starts_with(' ') && text.ends_with(' ')
|
||||||
|
};
|
||||||
|
assert!(inset(&pane_title("2", "Results", true)));
|
||||||
|
assert!(inset(&pane_title("Info", "", true)));
|
||||||
|
assert!(inset(&plain_title("Status", false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_status_glyphs_carry_their_semantic_color() {
|
||||||
|
for (status, glyph, color) in [
|
||||||
|
(DownloadStatus::Queued, GLYPH_QUEUED, TAPE),
|
||||||
|
(DownloadStatus::Completed, GLYPH_DONE, PHOSPHOR),
|
||||||
|
(DownloadStatus::Failed(None), GLYPH_FAILED, ALARM),
|
||||||
|
(DownloadStatus::TimedOut, GLYPH_TIMED_OUT, ALARM),
|
||||||
|
] {
|
||||||
|
let (got_glyph, style) = download_status_glyph(&status);
|
||||||
|
assert_eq!(got_glyph, glyph);
|
||||||
|
assert_eq!(style.fg, Some(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_bar_splits_fill_and_track() {
|
||||||
|
let bar = format_progress_bar(0.25, 20, 25);
|
||||||
|
assert_eq!(bar.spans[1].content.chars().count(), 5);
|
||||||
|
assert_eq!(bar.spans[1].style.fg, Some(SIGNAL));
|
||||||
|
assert_eq!(bar.spans[2].content.chars().count(), 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user