use ratatui::
{
Frame,
backend::FromCrossterm,
style::{ Color, Style },
text::{ Line, Span },
widgets::
{
Block,
BorderType,
Clear,
Paragraph,
},
layout::
{
Constraint,
Layout,
Position,
Rect,
},
};
use unicode_width::UnicodeWidthStr;
use crate::
{
config,
options,
};
#[cfg(feature = "client_voice")]
use crate::network::voice::client::options as voice_options;
use super::
{
theme,
state::{ self, App },
palette::
{
self,
Entry,
Values,
PaletteMode,
},
tofu::
{
self,
Prompt,
Stage,
},
settings::
{
self,
Row,
Value,
Settings,
DeviceEntry,
},
login::
{
Login,
Stage as LoginStage,
},
};
const SIDEBAR_WIDTH: u16 = 24;
const SIDEBAR_MIN_TERM_WIDTH: u16 = 70; const INPUT_MIN_HEIGHT: u16 = 3;
const INPUT_MAX_HEIGHT: u16 = 8;
const CHANNELS_MIN_HEIGHT: u16 = 12; const SETTINGS_WIDTH: u16 = 62; const TOFU_WIDTH: u16 = 64; const LOGIN_WIDTH: u16 = 52; const FIELD_ROW: u16 = 1; const SETTINGS_VALUE_WIDTH: u16 = 20;
const LOGO: &str = include_str!("./assets/rexlogo");
#[cfg(feature = "client_voice")]
const SLIDER_WIDTH: usize = 14;
const SCROLL_GAP: usize = 4;
enum Panel {
Online,
Channels,
Voice,
}
pub fn draw(frame: &mut Frame, app: &mut App)
{
let area = frame.area();
let connecting = app.login.is_some();
frame.buffer_mut().set_style(area, theme::TEXT);
let input_width = area.width.saturating_sub(4).max(1); let (input_lines, cursor) = app.input.render(input_width, false);
let input_height = if connecting { 0 }
else { (input_lines.len() as u16 + 2).clamp(INPUT_MIN_HEIGHT, INPUT_MAX_HEIGHT) };
let [main_area, input_area] = Layout::vertical
([
Constraint::Min(INPUT_MIN_HEIGHT),
Constraint::Length(input_height),
]).areas(area);
let (messages_area, sidebar_area) = if area.width >= SIDEBAR_MIN_TERM_WIDTH && options::get_sending_messages()
{
let [m, s] = Layout::horizontal([Constraint::Min(0), Constraint::Length(SIDEBAR_WIDTH)]).areas(main_area);
(m, Some(s))
} else
{
(main_area, None)
};
draw_messages(frame, app, messages_area);
if let Some(sidebar_area) = sidebar_area { draw_sidebar(frame, app, sidebar_area); }
if !connecting { draw_input(frame, app, input_area, input_lines, cursor); }
if !app.theme.disable_logo { draw_logo(frame, area); }
if app.palette.is_visible() { draw_palette(frame, app, messages_area); }
if app.settings.open { draw_settings(frame, &mut app.settings, area); }
if let Some(login) = &app.login { draw_login(frame, login, area); }
if let Some(prompt) = &app.tofu { draw_tofu(frame, prompt, area); }
}
fn draw_messages(frame: &mut Frame, app: &mut App, area: Rect)
{
let mut parts = vec![String::from("WHY2")];
if !app.server_name.is_empty() { parts.push(app.server_name.clone()); }
if !app.address.is_empty() { parts.push(app.address.clone()); }
if options::socks5_enabled() { parts.push(String::from("SOCKS5")); }
let title = format!(" {} ", parts.join(" ── "));
let mut block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER)
.title(Span::styled(title, theme::TITLE));
if app.scroll.is_some() && app.unread > 0
{
block = block.title_bottom(Line::from(Span::styled(format!(" ↓ {} new ", app.unread), theme::NOTICE)).right_aligned());
}
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 { return; }
let viewport = inner.height;
let total = app.wrapped_lines(inner.width).len() as u16;
let max_offset = total.saturating_sub(viewport);
let offset = app.scroll.map(|o| o.min(max_offset)).unwrap_or(max_offset);
let visible = app.wrapped_lines(inner.width)
.iter()
.skip(offset as usize)
.take(viewport as usize)
.cloned()
.collect::<Vec<Line<'static>>>();
frame.render_widget(Paragraph::new(visible), inner);
draw_scrollbar(frame, area, total as usize, viewport as usize, offset as usize);
}
fn window(offset: usize, selected: usize, total: usize, visible: usize) -> usize
{
let max = total.saturating_sub(visible);
let gap = SCROLL_GAP.min(visible.saturating_sub(1) / 2);
let mut first = offset.min(max);
if selected < first + gap { first = selected.saturating_sub(gap); }
if selected + gap >= first + visible { first = (selected + gap + 1).saturating_sub(visible); }
first.min(max)
}
fn draw_scrollbar(frame: &mut Frame, area: Rect, total: usize, visible: usize, first: usize)
{
if total <= visible || visible == 0 || area.width == 0 || area.height < 3 { return; }
let track = area.height as usize - 2;
if track == 0 { return; }
let max_first = total - visible;
let thumb = ((visible * track + total / 2) / total).clamp(1, track);
let room = track - thumb;
let start = if max_first == 0 { 0 } else { (first.min(max_first) * room + max_first / 2) / max_first };
let x = area.x + area.width - 1;
let buffer = frame.buffer_mut();
for row in 0..track
{
let Some(cell) = buffer.cell_mut((x, area.y + 1 + row as u16)) else { continue; };
if row >= start && row < start + thumb
{
cell.set_symbol("\u{2588}");
cell.set_style(theme::ACCENT);
} else
{
cell.set_symbol("\u{2502}");
cell.set_style(theme::BORDER);
}
}
}
fn draw_logo(frame: &mut Frame, area: Rect)
{
let rows = LOGO.lines().collect::<Vec<&str>>();
let height = rows.len() as u16;
let width = rows.iter().map(|row| row.chars().count()).max().unwrap_or(0) as u16;
if width == 0 || area.width < width || area.height < height { return; }
let x = area.x + (area.width - width) / 2;
let y = area.y + (area.height - height) / 2;
let buffer = frame.buffer_mut();
for (row_index, row) in rows.iter().enumerate()
{
for (column, symbol) in row.chars().enumerate()
{
if symbol == ' ' { continue; }
let Some(cell) = buffer.cell_mut((x + column as u16, y + row_index as u16)) else { continue; };
if cell.symbol().trim().is_empty() {
cell.set_char(symbol);
cell.set_style(theme::LOGO);
} else if cell.bg == Color::Reset {
cell.set_style(theme::LOGO_UNDER);
}
}
}
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect)
{
let mut constraints = vec![Constraint::Min(3)];
let mut panels = vec![Panel::Online];
let limit = area.height.saturating_sub(3).max(3);
if area.height >= CHANNELS_MIN_HEIGHT && !app.channels.is_empty()
{
constraints.push(Constraint::Length((app.channels.len() as u16 + 2).clamp(3, limit)));
panels.push(Panel::Channels);
}
if voice_visible(app)
{
constraints.push(Constraint::Length((app.voice.len() as u16 + 2).clamp(3, limit)));
panels.push(Panel::Voice);
}
let areas = Layout::vertical(constraints).split(area);
for (area, panel) in areas.iter().zip(panels)
{
match panel
{
Panel::Online => draw_online(frame, app, *area),
Panel::Channels => draw_channels(frame, app, *area),
Panel::Voice => draw_voice(frame, app, *area),
}
}
}
fn draw_online(frame: &mut Frame, app: &App, area: Rect)
{
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER)
.title(Span::styled(format!(" Online ({}) ", app.online.len()), theme::TITLE));
let inner = block.inner(area);
frame.render_widget(block, area);
let width = app.online.iter().map(|user| user.id.to_string().len()).max().unwrap_or(1);
let me = app.username.clone();
let lines = app.online.iter().map(|user|
{
let style = if user.username == me { theme::ACCENT } else { Style::default() };
Line::from(vec!
[
Span::styled(format!("{id:>width$} ", id = user.id), theme::DIM),
Span::styled(user.username.clone(), style),
])
}).collect::<Vec<Line>>();
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_channels(frame: &mut Frame, app: &App, area: Rect)
{
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER)
.title(Span::styled(format!(" Channels ({}) ", app.channels.len()), theme::TITLE));
let inner = block.inner(area);
frame.render_widget(block, area);
let current = options::get_channel();
let lines = app.channels.iter().map(|name|
{
let here = current == *name;
Line::from(vec!
[
Span::styled(if here { "▸ " } else { " " }, theme::ACCENT),
Span::styled("#", theme::DIM),
Span::styled(name.clone(), if here { theme::ACCENT } else { Style::default() }),
])
}).collect::<Vec<Line>>();
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_voice(frame: &mut Frame, app: &App, area: Rect)
{
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER)
.title(Span::styled(" Voice ", theme::TITLE));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = app.voice.iter().map(|user|
{
#[cfg(feature = "client_voice")]
let muted = app.voice_enabled && options::is_muted(if user.is_local { None } else { Some(user.id) });
#[cfg(not(feature = "client_voice"))]
let muted = false;
let marker = if muted { "✕" } else if user.is_speaking { "●" } else { "○" };
let style = if muted
{
theme::ERROR
} else if user.is_speaking
{
theme::SPEAKING
} else
{
theme::DIM
};
let latency = match user.latency
{
Some(latency) => format!(" {latency}ms"),
None => String::new(),
};
Line::from(vec!
[
Span::styled(format!("{marker} {}", user.username), style),
Span::styled(latency, theme::DIM),
])
}).collect::<Vec<Line>>();
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_input(frame: &mut Frame, app: &App, area: Rect, lines: Vec<Line<'static>>, cursor: (u16, u16))
{
let channel = match options::get_channel()
{
c if c.is_empty() => String::new(),
c => format!(" #{c} "),
};
let left = match (channel.trim(), app.username.as_str())
{
("", "") => String::new(),
(c, "") => format!(" {c} "),
("", u) => format!(" {u} "),
(c, u) => format!(" {c} │ {u} "),
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER_ACTIVE)
.title_bottom(Line::from(Span::styled(left, theme::DIM)))
.title_bottom(Line::from(Span::styled(right_status(app), theme::DIM)).right_aligned());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 { return; }
let [gutter, text_area] = Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
frame.render_widget(Paragraph::new(Span::styled("> ", theme::ACCENT)), gutter);
let offset = cursor.1.saturating_sub(text_area.height.saturating_sub(1));
frame.render_widget(Paragraph::new(lines).scroll((offset, 0)), text_area);
if app.settings.open || app.tofu.is_some() || app.login.is_some() { return; }
frame.set_cursor_position(Position::new
(
text_area.x + cursor.0.min(text_area.width.saturating_sub(1)),
text_area.y + cursor.1.saturating_sub(offset),
));
}
fn draw_palette(frame: &mut Frame, app: &mut App, area: Rect)
{
let (total, selected, title) = match &app.palette.mode
{
PaletteMode::Hidden => return,
PaletteMode::Menu(matches, selected) => (matches.len(), *selected, String::from(" Commands ")),
PaletteMode::Values(values) =>
(values.matches.len(), values.selected, format!(" {} ", capitalize(values.arg.name))),
PaletteMode::Signature(..) => (1, 0, String::from(" Parameters ")),
};
let rows = total.min(palette::MAX_ROWS);
let first = window(app.palette.offset, selected, total, rows);
app.palette.offset = first;
let height = rows as u16 + 2;
if area.height < height || area.width < 10 { return; }
let popup = Rect
{
x: area.x,
y: area.y + area.height - height,
width: area.width,
height,
};
frame.render_widget(Clear, popup);
frame.buffer_mut().set_style(popup, theme::TEXT);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER_ACTIVE)
.title(Span::styled(title, theme::TITLE));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let lines = match &app.palette.mode
{
PaletteMode::Values(values) => value_lines(values, rows, first),
_ => entry_lines(app, rows, first, inner.width as usize),
};
frame.render_widget(Paragraph::new(lines), inner);
draw_scrollbar(frame, popup, total, rows, first);
}
fn value_lines(values: &Values, rows: usize, first: usize) -> Vec<Line<'static>>
{
values.matches.iter().skip(first).take(rows).enumerate().map(|(row, value)|
{
let selected = first + row == values.selected;
let mut spans = vec![Span::styled(if selected { "▌" } else { " " }, theme::ACCENT)];
if let Some(color) = values.swatch(value)
{
spans.push(Span::styled(" ", Style::new().bg(Color::from_crossterm(color))));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(value.clone()));
let line = Line::from(spans);
if selected { line.style(theme::SELECTED) } else { line }
}).collect()
}
fn entry_lines(app: &App, rows: usize, first: usize, width: usize) -> Vec<Line<'static>>
{
let (entries, selected) = match &app.palette.mode
{
PaletteMode::Menu(matches, selected) =>
{
let entries = matches.iter().copied()
.skip(first)
.take(rows)
.map(|entry| (entry, None))
.collect::<Vec<(Entry, Option<usize>)>>();
(entries, Some(selected - first))
},
PaletteMode::Signature(entry, active) => (vec![(*entry, *active)], None),
_ => return Vec::new(),
};
let signature_width = entries.iter().map(|(entry, _)| entry.width()).max().unwrap_or(0);
let shortcut_width = entries.iter().map(|(entry, _)| entry.shortcut().width()).max().unwrap_or(0);
entries.iter().enumerate().map(|(row, (entry, active))|
{
let mut spans = vec![Span::styled(if Some(row) == selected { "▌" } else { " " }, theme::ACCENT)];
let description = active.and_then(|i| entry.args().get(i)).map_or(entry.description(), |arg| arg.description);
spans.extend(entry.spans(*active));
spans.push(Span::raw(" ".repeat(signature_width - entry.width() + 2)));
spans.push(Span::styled(description.to_string(), theme::DIM));
if shortcut_width > 0
{
let used = 1 + signature_width + 2 + description.width();
let shortcut = entry.shortcut();
spans.push(Span::raw(" ".repeat(width.saturating_sub(used + shortcut_width + 1))));
spans.push(Span::styled(format!("{shortcut:>shortcut_width$} "), theme::ACCENT));
}
let line = Line::from(spans);
if Some(row) == selected { line.style(theme::SELECTED) } else { line }
}).collect()
}
fn capitalize(name: &str) -> String
{
let mut chars = name.chars();
match chars.next()
{
Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
None => String::new(),
}
}
fn draw_settings(frame: &mut Frame, state: &mut Settings, area: Rect)
{
let width = SETTINGS_WIDTH.min(area.width.saturating_sub(2)).max(1);
let inner_width = width.saturating_sub(2) as usize;
if area.height < 5 || inner_width < 12 { return; }
let (title, total, selected) = match &state.picker
{
Some(picker) => (picker.title.to_string(), picker.entries.len(), picker.selected),
None => (state.title(), state.rows.len(), state.selected),
};
let hint_lines = match state.picker.is_none().then(|| state.rows.get(state.selected)).flatten()
{
Some(row) => description_lines(state, row, inner_width as u16),
None => Vec::new(),
};
let hint_height = match state.picker.is_some()
{
true => 0,
false => state.rows.iter()
.map(|row| description_lines(state, row, inner_width as u16).len())
.max().unwrap_or(0),
};
let room = area.height.saturating_sub(4) as usize;
let footer = match hint_height { 0 => 0, height => height + 1 };
let footer = if room > footer { footer } else { 0 };
let rows_room = room - footer;
let visible = match &state.picker
{
Some(_) => total.min(settings::MAX_PICKER_ROWS).min(rows_room),
None => total.min(rows_room),
}.max(1);
let offset = match &state.picker
{
Some(picker) => picker.offset,
None => state.offset,
};
let first = window(offset, selected, total, visible);
state.page = visible;
match state.picker.as_mut()
{
Some(picker) => picker.offset = first,
None => state.offset = first,
}
let label_width = state.rows.iter().filter_map(|row| match row
{
Row::Item(item) => Some(item.label.width()),
Row::Header(_) | Row::Action(_) => None,
}).max().unwrap_or(0).min(inner_width.saturating_sub(SETTINGS_VALUE_WIDTH as usize + 3));
let mut lines = match &state.picker
{
Some(picker) => picker.entries.iter().enumerate()
.skip(first)
.take(visible)
.map(|(index, entry)| picker_line(entry, index == picker.selected, inner_width))
.collect::<Vec<Line>>(),
None => state.rows.iter().enumerate()
.skip(first)
.take(visible)
.map(|(index, row)| settings_line(state, row, index == state.selected, label_width, inner_width))
.collect::<Vec<Line>>(),
};
let rows_height = lines.len() as u16 + 2;
if footer > 0
{
lines.push(Line::from(Span::styled("\u{2500}".repeat(inner_width), theme::BORDER)));
let blanks = hint_height - hint_lines.len();
lines.extend(hint_lines);
lines.extend(std::iter::repeat_n(Line::default(), blanks));
}
let height = lines.len() as u16 + 2;
let popup = Rect
{
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, popup);
frame.buffer_mut().set_style(popup, theme::TEXT);
let hint = match (&state.picker, state.edit.is_some(), state.server)
{
(Some(_), ..) => " ↑↓ select │ ⏎ apply │ Esc back ",
(None, true, _) => " type a value │ ⏎ keep │ Esc cancel ",
(None, false, true) => " ↑↓ move │ ←→ change │ ⏎ edit │ ^S save │ Esc close ",
(None, false, false) => " ↑↓ move │ ←→ change │ ⏎ select │ Esc close ",
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER_ACTIVE)
.title(Span::styled(title, theme::TITLE))
.title_bottom(Line::from(Span::styled(hint, theme::DIM)).centered());
let inner = block.inner(popup);
frame.render_widget(block, popup);
frame.render_widget(Paragraph::new(lines), inner);
draw_scrollbar(frame, Rect { height: rows_height, ..popup }, total, visible, first);
}
fn draw_tofu(frame: &mut Frame, prompt: &Prompt, area: Rect)
{
let width = TOFU_WIDTH.min(area.width.saturating_sub(2)).max(1);
let inner_width = width.saturating_sub(4);
if area.height < 9 || inner_width < 20 { return; }
let confirming = prompt.stage == Stage::Confirm;
let warning = match (confirming, prompt.mismatch)
{
(true, _) => "Replacing a pinned key throws away the only thing that would \
catch an interception. Do it only after checking the fingerprint with \
the operator over a channel this server cannot touch.",
(false, true) => "The server is presenting a different identity key than the one \
pinned for this address. Either the operator replaced the server's \
keys, or somebody is sitting between you and it.",
(false, false) => "This address has no pinned identity key yet. Accept it only if the \
fingerprint below matches the one the server's operator published.",
};
let mut lines = state::wrap_line(&Line::from(Span::styled(warning, theme::NOTICE)), inner_width);
lines.push(Line::default());
lines.push(Line::from(vec!
[
Span::styled("Server ", theme::DIM),
Span::raw(prompt.host.clone()),
]));
for (index, row) in prompt.pinned_fingerprint().into_iter().enumerate()
{
lines.push(Line::from(vec!
[
Span::styled(if index == 0 { "Pinned " } else { " " }, theme::DIM),
Span::styled(row, theme::DIM),
]));
}
let label = if prompt.mismatch { "New key " } else { "Key " };
for (index, row) in prompt.fingerprint().into_iter().enumerate()
{
lines.push(Line::from(vec!
[
Span::styled(if index == 0 { label } else { " " }, theme::DIM),
Span::styled(row, theme::ACCENT),
]));
}
lines.push(Line::default());
if confirming
{
let typed = prompt.typed.chars().count();
lines.append(&mut state::wrap_line(&Line::from(Span::styled(format!
(
"Type '{}' to replace the pinned key with this one:",
tofu::CHALLENGE,
), theme::TEXT)), inner_width));
lines.push(Line::from(vec!
[
Span::styled(prompt.typed.clone(), theme::ACCENT),
Span::styled("_".repeat(tofu::CHALLENGE.chars().count().saturating_sub(typed)), theme::DIM),
]).centered());
if prompt.wrong
{
lines.push(Line::from(Span::styled(format!("Type '{}' to go through with it.", tofu::CHALLENGE),
theme::ERROR)).centered());
}
} else
{
lines.push(Line::from(vec!
[
button(" Reject ", !prompt.accept, theme::ERROR),
Span::raw(" "),
button(if prompt.mismatch { " Replace pinned key " } else { " Trust & save " }, prompt.accept, theme::OK),
]).centered());
}
let height = (lines.len() as u16 + 2).min(area.height);
let popup = Rect
{
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, popup);
frame.buffer_mut().set_style(popup, theme::TEXT);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::ERROR)
.title(Span::styled(prompt.title(), theme::ERROR))
.title_bottom(Line::from(Span::styled(if confirming
{
" type the word │ ⏎ confirm │ ← back │ Esc reject "
} else { " ←→ choose │ ⏎ confirm │ Esc reject " }, theme::DIM)).centered());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [_, text_area, _] = Layout::horizontal
([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
]).areas(inner);
frame.render_widget(Paragraph::new(lines), text_area);
}
fn draw_login(frame: &mut Frame, login: &Login, area: Rect)
{
let width = LOGIN_WIDTH.min(area.width.saturating_sub(2)).max(1);
let inner_width = width.saturating_sub(4); let field_width = inner_width.saturating_sub(2);
if area.height < 8 || field_width < 8 { return; }
let (field, cursor) = login.input.render(field_width, login.masked());
let mut lines = vec![Line::from(Span::styled(login.label(), theme::DIM))];
for (index, line) in field.into_iter().enumerate()
{
let mut spans = vec![Span::styled(if index == 0 { "> " } else { " " }, theme::ACCENT)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
lines.push(Line::default());
let status = match (login.busy, login.error.as_deref(), login.hint.as_deref())
{
(true, ..) => Line::from(Span::styled(login.waiting(), theme::ACCENT)),
(false, Some(error), _) => Line::from(Span::styled(error.to_string(), theme::ERROR)),
(false, None, Some(hint)) => Line::from(Span::styled(hint.to_string(), theme::DIM)),
(false, None, None) => Line::default(),
};
lines.extend(state::wrap_line(&status, inner_width));
if login.stage == LoginStage::Address && options::socks5_enabled()
{
let proxy = Line::from(Span::styled(format!("Through SOCKS5 {}",
config::read_config::<String>("socks5_addr")), theme::DIM));
lines.extend(state::wrap_line(&proxy, inner_width));
}
let height = (lines.len() as u16 + 2).min(area.height);
let popup = Rect
{
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
frame.render_widget(Clear, popup);
frame.buffer_mut().set_style(popup, theme::TEXT);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::BORDER_ACTIVE)
.title(Span::styled(login.title(), theme::TITLE))
.title_bottom(Line::from(Span::styled(match (login.stage, login.busy, login.cancellable())
{
(_, true, true) => " Esc cancel ",
(_, true, false) => " Esc quit ",
(LoginStage::Address, false, _) => " ⏎ connect │ Esc quit ",
(_, false, _) => " ⏎ continue │ Esc quit ",
}, theme::DIM)).centered());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [_, text_area, _] = Layout::horizontal
([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
]).areas(inner);
frame.render_widget(Paragraph::new(lines), text_area);
if !login.busy
{
frame.set_cursor_position(Position::new
(
text_area.x + 2 + cursor.0.min(field_width.saturating_sub(1)),
text_area.y + FIELD_ROW + cursor.1,
));
}
}
fn button(label: &'static str, selected: bool, style: Style) -> Span<'static>
{
if selected { Span::styled(label, style.patch(theme::SELECTED)) } else { Span::styled(label, theme::DIM) }
}
fn description_lines(state: &Settings, row: &Row, width: u16) -> Vec<Line<'static>>
{
let mut spans = Vec::new();
match row
{
Row::Header(_) => return Vec::new(),
Row::Action(label) if **label == *settings::RESTART_LABEL =>
{
spans.push(Span::styled("Restart the server \u{2014} every client is disconnected and the whole config is read again.", theme::DIM));
if state.unsaved() { spans.push(Span::styled(" \u{b7} save your changes first", theme::NOTICE)); }
else if state.confirm { spans.push(Span::styled(" \u{b7} press again to confirm", theme::ERROR)); }
},
Row::Action(_) => spans.push(Span::styled("Send the edited rows to the server.", theme::DIM)),
Row::Item(item) =>
{
if !item.hint.is_empty() { spans.push(Span::styled(item.hint.clone(), theme::DIM)); }
if item.restart
{
let note = match spans.is_empty() { true => "restart required", false => " \u{b7} restart required" };
spans.push(Span::styled(note, theme::NOTICE));
}
},
}
if spans.is_empty() { return Vec::new(); }
state::wrap_line(&Line::from(spans), width)
}
fn settings_line(_state: &Settings, row: &Row, selected: bool, label_width: usize, width: usize) -> Line<'static>
{
let item = match row
{
Row::Header(label) => return Line::from(vec!
[
Span::styled(format!(" {label} "), theme::TITLE),
Span::styled("─".repeat(width.saturating_sub(label.width() + 2)), theme::BORDER),
]),
Row::Action(label) =>
{
let restart = **label == *settings::RESTART_LABEL;
let live = if restart { !_state.unsaved() } else { _state.unsaved() };
let armed = restart && _state.confirm;
let style = match (armed, selected, live)
{
(true, _, _) => theme::ERROR,
(_, true, _) => theme::ACCENT,
(_, false, true) => theme::TEXT,
(_, false, false) => theme::DIM,
};
let text = match armed
{
true => format!("[ {label} \u{b7} press again ]"),
false => format!("[ {label} ]"),
};
let padding = width.saturating_sub(text.width() + 1) / 2;
let line = Line::from(vec!
[
Span::styled(if selected { "▌" } else { " " }, theme::ACCENT),
Span::raw(" ".repeat(padding)),
Span::styled(text, style),
]);
return if selected { line.style(theme::SELECTED) } else { line };
},
Row::Item(item) => item,
};
let mut spans = vec!
[
Span::styled(if selected { "▌" } else { " " }, theme::ACCENT),
Span::styled
(
format!(" {:<label_width$} ", truncate(&item.label, label_width)),
if selected { theme::ACCENT } else { theme::TEXT },
),
];
let value_width = width.saturating_sub(label_width + 3);
match _state.edit.as_ref().filter(|_| selected)
{
Some(edit) => spans.push(Span::styled(format!("{}▏", truncate(edit, value_width.saturating_sub(1))), theme::ACCENT)),
None => spans.extend(value_spans(_state, &item.value, value_width)),
}
if item.changed { spans.push(Span::styled(" ●", theme::NOTICE)); }
if item.restart { spans.push(Span::styled(" ↻", theme::DIM)); }
let line = Line::from(spans);
if selected { line.style(theme::SELECTED) } else { line }
}
fn value_spans(_state: &Settings, value: &Value, _width: usize) -> Vec<Span<'static>>
{
match value
{
Value::Toggle { on: true, .. } => vec![Span::styled("● on", theme::OK)],
Value::Toggle { on: false, .. } => vec![Span::styled("○ off", theme::DIM)],
Value::Number(number) => vec![Span::styled(number.to_string(), theme::TEXT)],
Value::Text(text) if text.is_empty() => vec![Span::styled("(empty)", theme::DIM)],
Value::Text(text) => vec![Span::styled(truncate(text, _width), theme::TEXT)],
#[cfg(feature = "client_voice")]
Value::Volume(percent) =>
{
let filled = (*percent as usize * SLIDER_WIDTH).div_ceil(voice_options::VOLUME_MAX as usize);
vec!
[
Span::styled("█".repeat(filled), theme::ACCENT),
Span::styled("░".repeat(SLIDER_WIDTH.saturating_sub(filled)), theme::BORDER),
Span::styled(format!(" {percent:>3}%"), if *percent == 0 { theme::DIM } else { theme::TEXT }),
]
},
#[cfg(feature = "client_voice")]
Value::Device { id, input } =>
{
if id.is_empty()
{
vec![Span::styled(settings::DEFAULT_DEVICE, theme::DIM)]
} else
{
vec![Span::styled(truncate(&_state.device_label(id, *input), _width), theme::ACCENT)]
}
},
}
}
#[cfg(feature = "client_voice")]
fn picker_line(entry: &DeviceEntry, selected: bool, width: usize) -> Line<'static>
{
let (text, style) = if entry.id.is_empty()
{
(String::from(settings::DEFAULT_DEVICE), theme::DIM)
} else
{
(truncate(&entry.label, width.saturating_sub(3)), theme::TEXT)
};
let line = Line::from(vec!
[
Span::styled(if selected { "▌" } else { " " }, theme::ACCENT),
Span::styled(format!(" {text}"), style),
]);
if selected { line.style(theme::SELECTED) } else { line }
}
#[cfg(not(feature = "client_voice"))]
fn picker_line(_entry: &DeviceEntry, _selected: bool, _width: usize) -> Line<'static> { Line::default() }
fn truncate(text: &str, width: usize) -> String {
if text.width() <= width { return text.to_string(); }
let mut out = String::new();
let mut used = 0;
for c in text.chars()
{
let next = used + c.to_string().width();
if next > width.saturating_sub(1) { break; }
out.push(c);
used = next;
}
out.push('…');
out
}
fn right_status(_app: &App) -> String
{
let mut parts: Vec<String> = Vec::new();
#[cfg(feature = "client_voice")]
if _app.voice_enabled
{
let off = options::is_muted(None) || voice_options::get_input_volume() == 0;
parts.push(String::from(if off { "mic off" } else { "mic on" }));
}
parts.push(String::from("Ctrl+, settings"));
format!(" {} ", parts.join(" │ "))
}
fn voice_visible(app: &App) -> bool
{
!app.voice.is_empty()
}