use ratatui::
{
Frame,
backend::FromCrossterm,
buffer::{ Cell, CellDiffOption },
style::{ Color, Modifier, Style },
text::{ Line, Span },
widgets::
{
Block,
BorderType,
Clear,
Paragraph,
},
layout::
{
Constraint,
Layout,
Position,
Rect,
},
};
use unicode_width::UnicodeWidthStr;
use ratatui_image::{ CropOptions, FontSize, Resize, ResizeEncodeRender };
use crate::
{
t,
i18n,
config,
options,
role::Role,
consts as chat_consts,
};
#[cfg(feature = "client_voice")]
use crate::network::voice::
{
consts as voice_consts,
client::options as voice_options,
};
use super::
{
consts,
theme,
state::{ self, App },
palette::
{
Entry,
Values,
PaletteMode,
},
tofu::
{
Prompt,
Stage,
},
settings::
{
Mode,
Row,
Value,
Settings,
DeviceEntry,
},
login::
{
Login,
Reconnect,
Stage as LoginStage,
},
account::{ Account, Kind },
input::InputBuffer,
};
const LOGO: &str = include_str!("./assets/rexlogo");
enum Panel {
Icon,
Online,
Offline,
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::base());
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(consts::INPUT_MIN_HEIGHT, consts::INPUT_MAX_HEIGHT) };
let [main_area, input_area] = Layout::vertical
([
Constraint::Min(consts::INPUT_MIN_HEIGHT),
Constraint::Length(input_height),
]).areas(area);
let (messages_area, sidebar_area) = if area.width >= consts::SIDEBAR_MIN_TERM_WIDTH && options::get_sending_messages()
{
let [m, s] = Layout::horizontal([Constraint::Min(0), Constraint::Length(consts::SIDEBAR_WIDTH)]).areas(main_area);
(m, Some(s))
} else
{
(main_area, None)
};
draw_messages(frame, app, messages_area);
app.icon_area = sidebar_area.map_or(Rect::ZERO, |sidebar_area| draw_sidebar(frame, app, sidebar_area));
app.load_icon();
if !connecting { draw_input(frame, app, input_area, input_lines, cursor); }
if !app.theme.disable_logo { draw_logo(frame, area, app.stripe_bg); }
let mut overlays: Vec<Rect> = Vec::new();
if app.palette.is_visible() { overlays.push(draw_palette(frame, app, messages_area)); }
if app.settings.open
{
let font = app.picker.font_size();
overlays.push(draw_settings(frame, &mut app.settings, area, font));
}
if let Some(form) = &app.account { overlays.push(draw_account(frame, form, area)); }
if let Some(login) = &app.login { overlays.push(draw_login(frame, login, &app.reconnect, area)); }
if let Some(prompt) = &app.tofu { overlays.push(draw_tofu(frame, prompt, area)); }
let rewritten = draw_pictures(frame, app, &overlays);
draw_avatar(frame, app, &rewritten);
}
fn draw_avatar(frame: &mut Frame, app: &mut App, rewritten: &[u16])
{
let area = app.settings.picture_area;
if area.width == 0 || area.height == 0 { return; }
if app.login.is_some() || app.tofu.is_some() { return; }
app.load_avatar(area.width);
if let Some(ready) = app.settings.picture.as_mut() && let Some(protocol) = ready.protocol.as_mut()
{
protocol.resize_encode_render(&Resize::Crop(None), area, frame.buffer_mut());
let marks = rewritten.iter().copied().filter(|y| (area.y..area.y + area.height).contains(y)).map(|y|
{
let times = match app.avatar_marks.iter().find(|(row, _)| *row == y) { Some((_, 1)) => 2, _ => 1 };
mark(frame, area.x, y, times);
(y, times)
}).collect();
app.avatar_marks = marks;
}
}
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!(" ↓ {} ", t!("pane.unread", count = app.unread)), theme::notice())).right_aligned());
}
if let Some(notice) = app.notice()
{
block = block.title_bottom(Span::styled(format!(" {notice} "), theme::ok()));
}
if let Some(typing) = app.typing_line()
{
block = block.title_bottom(Span::styled(format!(" {typing} "), theme::dim()));
}
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);
app.pane = inner;
app.pane_offset = 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);
for y in inner.y..inner.y + viewport
{
let Some(tint) = app.tint(offset + (y - inner.y)) else { continue };
for x in inner.x..inner.x + inner.width
{
if let Some(cell) = frame.buffer_mut().cell_mut((x, y))
&& (cell.bg == Color::Reset || Some(cell.bg) == theme::background()) { cell.set_style(tint); }
}
}
for y in inner.y..inner.y + viewport
{
let Some((first, last)) = app.selection_columns(offset + (y - inner.y)) else { continue };
for x in first..=last.min(inner.width.saturating_sub(1))
{
if let Some(cell) = frame.buffer_mut().cell_mut((inner.x + x, y)) { cell.set_style(theme::selection()); }
}
}
app.load_visible(inner.width, offset, viewport);
draw_scrollbar(frame, area, total as usize, viewport as usize, offset as usize);
}
fn draw_pictures(frame: &mut Frame, app: &mut App, overlays: &[Rect]) -> Vec<u16>
{
let inner = app.pane;
let (offset, viewport) = (app.pane_offset, inner.height);
let mut pictures = Vec::new();
if inner.width > 0 && inner.height > 0
{
for placement in app.placements(inner.width)
{
let bottom = placement.row + placement.height;
let first = placement.row.max(offset);
let last = bottom.min(offset + viewport);
if last <= first { continue; }
let clip_top = offset.saturating_sub(placement.row) > bottom.saturating_sub(offset + viewport);
let area = Rect
{
x: inner.x,
y: inner.y + (first - offset),
width: inner.width,
height: last - first,
};
pictures.push((area, clip_top, Some(placement.entry)));
}
}
if app.icon_area.width > 0 && app.icon_area.height > 0 { pictures.push((app.icon_area, false, None)); }
let previous = app.overlays_drawn(overlays);
if previous != overlays { app.dirty = true; }
let mut rows = Vec::new();
for (area, clip_top, entry) in pictures
{
let covered = overlays.iter().any(|overlay| overlay.intersects(area));
let kept = if covered { overlay_cells(frame, area, overlays) } else { Vec::new() };
let (mut drawn, mut encoded) = (false, false);
let ready = match entry
{
Some(entry) => match app.messages.get_mut(entry)
{
Some(state::Entry::Image { picture: state::Picture::Ready(ready), .. }) => Some(ready),
_ => None,
},
None => app.icon.as_mut(),
};
if let Some(protocol) = ready.and_then(|ready| ready.protocol.as_mut())
{
let resize = Resize::Crop(Some(CropOptions { clip_top, clip_left: false }));
protocol.resize_encode_render(&resize, area, frame.buffer_mut());
drawn = true;
encoded = protocol.last_encoding_result().is_some();
}
if drawn && previous != overlays && previous.iter().any(|overlay| overlay.intersects(area))
{
replace_rows(frame, area, overlays);
}
if drawn && encoded { resend_rows(frame, app, area, overlays); }
if drawn
{
for y in area.y..area.y + area.height
{
if overlays.iter().any(|overlay| overlay.contains((area.x, y).into())) { continue; }
if let Some(cell) = frame.buffer_mut().cell((area.x, y)) { rows.push((y, cell.symbol().to_string())); }
}
}
for (x, y, cell) in kept
{
if let Some(target) = frame.buffer_mut().cell_mut((x, y))
{
*target = cell;
target.set_diff_option(CellDiffOption::AlwaysUpdate);
}
}
}
app.picture_rows_drawn(rows)
}
fn overlay_cells(frame: &mut Frame, area: Rect, overlays: &[Rect]) -> Vec<(u16, u16, Cell)>
{
let mut cells = Vec::new();
for y in area.y..area.y + area.height
{
for x in area.x..area.x + area.width
{
if !overlays.iter().any(|overlay| overlay.contains((x, y).into())) { continue; }
if let Some(cell) = frame.buffer_mut().cell((x, y)) { cells.push((x, y, cell.clone())); }
}
}
cells
}
fn replace_rows(frame: &mut Frame, area: Rect, overlays: &[Rect])
{
for y in area.y..area.y + area.height
{
if overlays.iter().any(|overlay| overlay.contains((area.x, y).into())) { continue; }
let Some(cell) = frame.buffer_mut().cell_mut((area.x, y)) else { continue };
let symbol = format!("\x1b[s{}", cell.symbol());
cell.set_symbol(&symbol);
}
}
fn resend_rows(frame: &mut Frame, app: &App, area: Rect, overlays: &[Rect])
{
for y in area.y..area.y + area.height
{
if overlays.iter().any(|overlay| overlay.contains((area.x, y).into())) { continue; }
let Some(cell) = frame.buffer_mut().cell_mut((area.x, y)) else { continue };
while app.picture_row_sent(y, cell.symbol())
{
let symbol = format!("\x1b[s{}", cell.symbol());
cell.set_symbol(&symbol);
}
}
}
fn mark(frame: &mut Frame, x: u16, y: u16, times: usize)
{
let Some(cell) = frame.buffer_mut().cell_mut((x, y)) else { return };
let symbol = format!("{}{}", "\x1b[s".repeat(times), cell.symbol());
cell.set_symbol(&symbol);
}
fn window(offset: usize, selected: usize, total: usize, visible: usize) -> usize
{
let max = total.saturating_sub(visible);
let gap = consts::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, stripe: Color)
{
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; };
let unpainted = cell.bg == Color::Reset || cell.bg == stripe || Some(cell.bg) == theme::mention().bg
|| Some(cell.bg) == theme::background();
if cell.symbol().trim().is_empty() && unpainted {
cell.set_char(symbol);
cell.set_style(theme::logo());
} else if unpainted {
cell.set_style(theme::logo_under());
}
}
}
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) -> Rect
{
let limit = area.height.saturating_sub(3).max(3);
let (mut constraints, mut panels) = (Vec::new(), Vec::new());
if area.height >= consts::ICON_MIN_HEIGHT && app.icon.is_some()
{
constraints.push(Constraint::Length(consts::ICON_ROWS + 2));
panels.push(Panel::Icon);
}
match app.offline.is_empty()
{
true =>
{
constraints.push(Constraint::Min(3));
panels.push(Panel::Online);
},
false =>
{
constraints.extend([Constraint::Length((online_rows(app) + 2).clamp(3, limit)), Constraint::Min(3)]);
panels.extend([Panel::Online, Panel::Offline]);
},
}
if area.height >= consts::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);
let mut icon = Rect::ZERO;
for (area, panel) in areas.iter().zip(panels)
{
match panel
{
Panel::Icon => icon = draw_icon(frame, app, *area),
Panel::Online => draw_online(frame, app, *area),
Panel::Offline => draw_offline(frame, app, *area),
Panel::Channels => draw_channels(frame, app, *area),
Panel::Voice => draw_voice(frame, app, *area),
}
}
icon
}
fn draw_icon(frame: &mut Frame, app: &App, area: Rect) -> Rect
{
let mut block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::border());
if !app.server_name.is_empty()
{
block = block.title(Span::styled(format!(" {} ", app.server_name), theme::title()));
}
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(ready) = app.icon.as_ref() else { return Rect::ZERO };
let (width, height) = state::picture_cells(&ready.frames[ready.current].image,
inner.width, inner.height.min(consts::ICON_ROWS), app.picker.font_size());
Rect
{
x: inner.x + inner.width.saturating_sub(width) / 2,
y: inner.y + inner.height.saturating_sub(height) / 2,
width,
height,
}
}
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!(" {} ", t!("sidebar.online")), theme::title()))
.title_top(Line::from(Span::styled(format!(" {} ", app.online.len()), theme::title())).right_aligned());
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 room = inner.width as usize;
let me = app.username.clone();
let mut lines = Vec::new();
for section in app.online_sections()
{
let label = section_label(section[0].role);
let count = section.len().to_string();
let pad = room.saturating_sub(label.width() + count.width() + 1);
lines.push(Line::from(vec!
[
Span::styled(label, theme::dim().add_modifier(Modifier::BOLD)),
Span::styled(format!("{:pad$}{count} ", ""), theme::dim()),
]));
lines.extend(section.iter().map(|user|
{
let style = match user.username == me
{
true => match app.color.filter(|_| !app.theme.disable_colors)
{
Some(_) => app.theme.style(app.color),
None => theme::accent(),
},
false => app.theme.style(user.username_color),
}.add_modifier(Modifier::BOLD);
let device = app.devices.get(&user.username).map(|device| super::device_label(device)).unwrap_or_default();
let reserved = if device.is_empty() { 0 } else { device.width() + 2 };
let name = truncate(&user.username, room.saturating_sub(width + 2 + reserved));
let mut spans = vec!
[
Span::styled(format!("{id:>width$} ", id = user.id), theme::dim()),
Span::styled(name.clone(), style),
];
if !device.is_empty()
{
let pad = room.saturating_sub(width + 3 + name.width() + device.width());
spans.push(Span::styled(format!("{:pad$}{device} ", ""), theme::dim()));
}
Line::from(spans)
}));
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn online_rows(app: &App) -> u16 {
(app.online.len() + app.online_sections().count()) as u16
}
fn section_label(role: Role) -> String {
let name = role.name();
i18n::get(&format!("sidebar.roles.{name}")).map(str::to_owned)
.unwrap_or_else(|| format!("{}{}s", name[..1].to_uppercase(), &name[1..]))
}
fn draw_offline(frame: &mut Frame, app: &App, area: Rect)
{
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::border())
.title(Span::styled(format!(" {} ", t!("sidebar.offline")), theme::title()))
.title_top(Line::from(Span::styled(format!(" {} ", app.offline.len()), theme::title())).right_aligned());
let inner = block.inner(area);
frame.render_widget(block, area);
let room = inner.width as usize;
let lines = app.offline.iter().map(|(username, color)|
{
let name = truncate(username, room);
Line::from(match color
{
Some(_) => Span::styled(name, app.theme.style(*color).add_modifier(Modifier::BOLD)),
None => Span::styled(name, theme::dim().add_modifier(Modifier::BOLD)),
})
}).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!(" {} ", t!("sidebar.channels")), theme::title()))
.title_top(Line::from(Span::styled(format!(" {} ", app.channels.len()), theme::title())).right_aligned());
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(format!(" {} ", t!("sidebar.voice")), theme::title()))
.title_top(Line::from(Span::styled(format!(" {} ", app.voice.len()), theme::title())).right_aligned());
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() || app.account.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) -> Rect
{
let (total, selected, title) = match &app.palette.mode
{
PaletteMode::Hidden => return Rect::ZERO,
PaletteMode::Menu(matches, selected) => (matches.len(), *selected, format!(" {} ", t!("palette.commands"))),
PaletteMode::Values(values) =>
(values.matches.len(), values.selected, format!(" {} ", capitalize(values.title()))),
PaletteMode::Signature(..) => (1, 0, format!(" {} ", t!("palette.parameters"))),
};
let rows = total.min(consts::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 Rect::ZERO; }
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::base());
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);
popup
}
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(theme::ansi(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| i18n::text(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, font: FontSize) -> Rect
{
let width = consts::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 Rect::ZERO; }
let (title, total, selected) = match &state.picker
{
Some(picker) => (format!(" {} ", picker.title), picker.entries.len(), picker.selected),
None => (format!(" {} ", 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 picture = match state.picker.is_some()
{
true => 0,
false => state.picture_rows(),
};
let room = (area.height.saturating_sub(4) as usize).saturating_sub(picture 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(consts::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(consts::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 picture > 0 { lines.splice(0..0, std::iter::repeat_n(Line::default(), picture as usize)); }
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::base());
let hint = match (state.picker.is_some(), state.edit.is_some())
{
(true, _) => t!("hint.settings.picker"),
(_, true) if state.editing_avatar() => t!("hint.settings.avatar"),
(_, true) => t!("hint.settings.edit"),
_ => match state.mode
{
Mode::Client => t!("hint.settings.client"),
Mode::Server => t!("hint.settings.server"),
Mode::Profile { own: true } => t!("hint.settings.own_profile"),
Mode::Profile { own: false } => match state.link().is_some()
{
true => t!("hint.settings.profile_link"),
false => t!("hint.settings.profile"),
},
},
};
let hint = format!(" {hint} ");
let mut 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());
if state.profile() && state.picker.is_none()
{
block = block.title_top(Line::from(Span::styled(format!(" {} ", state.role), theme::dim())).right_aligned());
}
let inner = block.inner(popup);
frame.render_widget(block, popup);
frame.render_widget(Paragraph::new(lines), inner);
state.picture_area = match state.picture.as_ref().filter(|_| picture > 0)
{
Some(ready) =>
{
let (width, height) = state::picture_cells(&ready.frames[ready.current].image,
inner.width, consts::AVATAR_ROWS, font);
Rect
{
x: inner.x + inner.width.saturating_sub(width) / 2,
y: inner.y + consts::AVATAR_ROWS.saturating_sub(height) / 2,
width,
height,
}
},
None => Rect::ZERO,
};
draw_scrollbar(frame, Rect { y: popup.y + picture, height: rows_height, ..popup }, total, visible, first);
popup
}
fn draw_tofu(frame: &mut Frame, prompt: &Prompt, area: Rect) -> Rect
{
let width = consts::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 Rect::ZERO; }
let confirming = prompt.stage == Stage::Confirm;
let warning = match (confirming, prompt.mismatch)
{
(true, _) => t!("tofu.warning.confirm"),
(false, true) => t!("tofu.warning.changed"),
(false, false) => t!("tofu.warning.unknown"),
};
let labels = [t!("tofu.server"), t!("tofu.pinned"), t!("tofu.new_key"), t!("tofu.key")];
let label_width = labels.iter().map(|label| label.width()).max().unwrap_or(0) + 2;
let column = |label: &str| format!("{label:<label_width$}");
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(column(t!("tofu.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(column(if index == 0 { t!("tofu.pinned") } else { "" }), theme::dim()),
Span::styled(row, theme::dim()),
]));
}
let label = if prompt.mismatch { t!("tofu.new_key") } else { t!("tofu.key") };
for (index, row) in prompt.fingerprint().into_iter().enumerate()
{
lines.push(Line::from(vec!
[
Span::styled(column(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(t!("tofu.type_to_replace", word = t!("tofu.challenge")),
theme::text())), inner_width));
lines.push(Line::from(vec!
[
Span::styled(prompt.typed.clone(), theme::accent()),
Span::styled("_".repeat(t!("tofu.challenge").chars().count().saturating_sub(typed)), theme::dim()),
]).centered());
if prompt.wrong
{
lines.push(Line::from(Span::styled(t!("tofu.type_to_confirm", word = t!("tofu.challenge")),
theme::error())).centered());
}
} else
{
lines.push(Line::from(vec!
[
button(t!("tofu.reject"), !prompt.accept, theme::error()),
Span::raw(" "),
button(if prompt.mismatch { t!("tofu.replace") } else { t!("tofu.trust") }, 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::base());
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::error())
.title(Span::styled(format!(" {} ", prompt.title()), theme::error()))
.title_bottom(Line::from(Span::styled(format!(" {} ", if confirming
{
t!("hint.tofu.confirm")
} else { t!("hint.tofu.warn") }), 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);
popup
}
fn draw_login(frame: &mut Frame, login: &Login, reconnect: &Reconnect, area: Rect) -> Rect
{
let mut notes = vec![match (login.busy, login.error.as_deref(), login.hint.as_deref())
{
(true, ..) => Line::from(Span::styled(reconnect.status()
.unwrap_or_else(|| login.waiting().to_owned()), 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(),
}];
if login.stage == LoginStage::Address && options::socks5_enabled()
{
notes.push(Line::from(Span::styled(t!("login.socks5", address = config::read_config::<String>("socks5_addr")),
theme::dim())));
}
let footer = match (login.stage, login.busy, login.cancellable())
{
(_, true, true) => t!("hint.login.cancel"),
(_, true, false) => t!("hint.login.quit"),
(LoginStage::Address, false, _) => t!("hint.login.connect"),
(_, false, _) => t!("hint.login.continue"),
};
draw_form(frame, area, login.title(), &[(login.label(), &login.input)], login.masked(), notes, footer, (!login.busy).then_some(0))
}
fn draw_account(frame: &mut Frame, form: &Account, area: Rect) -> Rect
{
let status = match (form.busy, form.armed, form.error.as_deref())
{
(true, ..) => Line::from(Span::styled(t!("login.waiting"), theme::accent())),
(false, true, _) => Line::from(Span::styled(t!("account.delete_warning"), theme::error())),
(false, false, Some(error)) => Line::from(Span::styled(error.to_string(), theme::error())),
(false, false, None) => Line::default(),
};
let footer = match (form.busy, form.kind, form.armed)
{
(true, ..) => "",
(false, Kind::Passwd, _) => t!("hint.account.passwd"),
(false, Kind::Delete, false) => t!("hint.account.delete"),
(false, Kind::Delete, true) => t!("hint.account.confirm"),
};
let fields: Vec<(&str, &InputBuffer)> = form.kind.labels().into_iter().zip(form.fields.iter()).collect();
draw_form(frame, area, form.kind.title(), &fields, true, vec![status], footer, (!form.busy).then_some(form.focus))
}
#[allow(clippy::too_many_arguments)]
fn draw_form
(
frame: &mut Frame,
area: Rect,
title: &str,
fields: &[(&str, &InputBuffer)],
masked: bool,
notes: Vec<Line<'static>>,
footer: &str,
focus: Option<usize>,
) -> Rect
{
let width = consts::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 Rect::ZERO; }
let mut lines: Vec<Line> = Vec::new();
let mut caret = None;
for (index, (label, input)) in fields.iter().enumerate()
{
let (field, cursor) = input.render(field_width, masked);
let focused = focus == Some(index);
if index > 0 { lines.push(Line::default()); }
if focused { caret = Some((cursor.0, lines.len() as u16 + consts::FIELD_ROW + cursor.1)); }
lines.push(Line::from(Span::styled(*label, theme::dim())));
for (row, line) in field.into_iter().enumerate()
{
let gutter = if row > 0 { " " } else { "> " };
let mut spans = vec![Span::styled(gutter, if focused { theme::accent() } else { theme::dim() })];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
lines.push(Line::default());
for note in ¬es { lines.extend(state::wrap_line(note, 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::base());
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme::border_active())
.title(Span::styled(format!(" {title} "), theme::title()))
.title_bottom(Line::from(Span::styled(if footer.is_empty() { String::new() } else { format!(" {footer} ") }, 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 let Some((column, row)) = caret
{
frame.set_cursor_position(Position::new
(
text_area.x + 2 + column.min(field_width.saturating_sub(1)),
text_area.y + row,
));
}
popup
}
fn button(label: &str, selected: bool, style: Style) -> Span<'static>
{
let label = format!(" {label} ");
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();
if let Row::Item(item) = row && item.key == consts::AVATAR_KEY && state.editing_avatar() && !state.paths.is_empty()
{
let visible = state.paths.len().min(consts::MAX_ROWS);
let first = window(0, state.path, state.paths.len(), visible);
return state.paths.iter().enumerate().skip(first).take(visible).map(|(index, path)|
{
let selected = index == state.path;
let line = Line::from(vec!
[
Span::styled(if selected { "▌ " } else { " " }, theme::accent()),
Span::styled(truncate(path, (width as usize).saturating_sub(2)), if selected { theme::accent() } else { theme::text() }),
]);
if selected { line.style(theme::selected()) } else { line }
}).collect();
}
match row
{
Row::Header(_) => return Vec::new(),
Row::Action(label) if **label == *consts::RESTART_LABEL =>
{
spans.push(Span::styled(t!("settings.restart_description"), theme::dim()));
if state.unsaved() { spans.push(Span::styled(format!(" \u{b7} {}", t!("settings.save_first")), theme::notice())); }
else if state.confirm { spans.push(Span::styled(format!(" \u{b7} {}", t!("settings.press_again_confirm")), theme::error())); }
},
Row::Action(_) if state.profile() =>
spans.push(Span::styled(t!("settings.save_profile_description"), theme::dim())),
Row::Action(_) => spans.push(Span::styled(t!("settings.save_description"), theme::dim())),
Row::Item(item) if state.profile() => match &item.value
{
Value::Avatar(Some(path)) if path.is_empty() =>
spans.push(Span::styled(t!("profile.avatar.removed"), theme::notice())),
Value::Avatar(Some(path)) => spans.push(Span::styled(t!("profile.avatar.uploaded", path), theme::text())),
Value::Avatar(None) => spans.push(Span::styled(t!("profile.avatar.help",
limit = chat_consts::MAX_IMAGE_SIZE / chat_consts::MEGABYTE), theme::dim())),
Value::Text(text) if text.is_empty() =>
spans.push(Span::styled(i18n::get(&format!("profile.empty.{}", item.key)).map(str::to_owned)
.unwrap_or_else(|| t!("profile.empty_field", field = item.label.to_lowercase())), theme::dim())),
Value::Text(text) => spans.push(Span::styled(text.clone(), theme::text())),
_ => {},
},
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 => t!("settings.restart_required").to_owned(), false => format!(" \u{b7} {}", t!("settings.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 == *consts::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 label = i18n::text(label);
let text = match armed
{
true => format!("[ {label} \u{b7} {} ]", t!("settings.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(format!("● {}", t!("settings.on")), theme::ok())],
Value::Toggle { on: false, .. } => vec![Span::styled(format!("○ {}", t!("settings.off")), theme::dim())],
Value::Number(number) => vec![Span::styled(number.to_string(), theme::text())],
Value::Text(text) if text.is_empty() => vec![Span::styled(format!("({})", t!("settings.empty")), theme::dim())],
Value::Text(text) => vec![Span::styled(truncate(text, _width), theme::text())],
Value::Avatar(None) if _state.avatar.is_some() => vec![Span::styled(t!("profile.avatar.set"), theme::text())],
Value::Avatar(None) => vec![Span::styled(format!("({})", t!("profile.avatar.none")), theme::dim())],
Value::Avatar(Some(path)) if path.is_empty() => vec![Span::styled(t!("profile.avatar.remove"), theme::notice())],
Value::Avatar(Some(path)) => vec![Span::styled(truncate(path, _width), theme::text())],
Value::Theme(index) => vec![Span::styled(format!("◂ {} ▸", theme::PALETTES[*index].name), theme::accent())],
Value::Language(code) => vec![Span::styled(format!("◂ {} ▸", i18n::language_name(code)), theme::accent())],
#[cfg(feature = "client_voice")]
Value::Volume(percent) =>
{
let filled = (*percent as usize * consts::SLIDER_WIDTH).div_ceil(voice_consts::VOLUME_MAX as usize);
vec!
[
Span::styled("█".repeat(filled), theme::accent()),
Span::styled("░".repeat(consts::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(t!("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()
{
(t!("settings.default_device").to_owned(), 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 { t!("status.mic_off") } else { t!("status.mic_on") }));
}
parts.push(t!("status.settings").to_owned());
format!(" {} ", parts.join(" │ "))
}
fn voice_visible(app: &App) -> bool
{
!app.voice.is_empty()
}