Lines
87.3 %
Functions
44.07 %
Branches
100 %
//! Ratatui rendering. The draw layer is a pure function of
//! [`crate::app::App`]: take a `Frame` and an `App`, render the current
//! visual state. No I/O of its own; I/O is the transport's job.
use crate::app::{App, Tab};
use crate::modal::{self, Modal};
use crate::tabs;
use crate::widgets::{EditMode, VimMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
draw_tabs(frame, chunks[0], app);
draw_body(frame, chunks[1], app);
draw_status(frame, chunks[2], app);
if let Some(modal) = app.modals.top() {
draw_modal(frame, area, modal);
}
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
let selected = Tab::ALL
.iter()
.position(|t| *t == app.active_tab)
.unwrap_or(0);
let tabs = Tabs::new(titles)
.select(selected)
.block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_widget(tabs, area);
fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
match app.active_tab {
Tab::Console => draw_console(frame, area, app),
_ => {
let body = tabs::placeholder_body(app.active_tab);
let widget = Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.title(app.active_tab.label()),
);
frame.render_widget(widget, area);
fn draw_console(frame: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::ALL).title("Console");
let inner = block.inner(area);
frame.render_widget(block, area);
Constraint::Length(1),
.split(inner);
draw_console_scrollback(frame, chunks[0], app);
draw_console_prompt(frame, chunks[1], app);
draw_console_hint(frame, chunks[2], app);
fn draw_console_scrollback(frame: &mut Frame, area: Rect, app: &App) {
let scrollback = &app.console.scrollback;
let visible = usize::from(area.height);
let start = scrollback.len().saturating_sub(visible);
let lines: Vec<Line> = scrollback[start..]
.map(|l| Line::from(l.as_str()))
.collect();
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
fn draw_console_prompt(frame: &mut Frame, area: Rect, app: &App) {
let marker = if app.console.pending.is_empty() {
"nms> "
} else {
"...> "
};
let content = format!("{marker}{}", app.console.input.buffer());
let style = if app.console_input_active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
Style::default().fg(Color::Gray)
frame.render_widget(Paragraph::new(Span::styled(content, style)), area);
fn draw_console_hint(frame: &mut Frame, area: Rect, app: &App) {
let hint = if app.console_input_active {
"Enter run Esc blur C-c interrupt Up/Down history"
"i/Enter focus 1-6 tabs Tab next q quit"
let line = Span::styled(hint, Style::default().fg(Color::DarkGray));
frame.render_widget(Paragraph::new(line), area);
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let edit_indicator = match app.command_line.mode() {
EditMode::Emacs => "emacs",
EditMode::Vim => match app.command_line.vim_mode() {
VimMode::Normal => "vim:normal",
VimMode::Insert => "vim:insert",
},
let content = if app.command_line_active {
format!(
":{} (cursor={})",
app.command_line.buffer(),
app.command_line.cursor()
)
} else if app.status.is_empty() {
format!("[{edit_indicator}] Tab/BTab tabs : cmdline ? help C-v edit-mode q quit")
format!("[{edit_indicator}] {}", app.status)
let line = Span::styled(content, Style::default().fg(Color::Gray));
let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
frame.render_widget(para, area);
fn draw_modal(frame: &mut Frame, full: Rect, modal: &Modal) {
let area = centered_rect(60, 30, full);
frame.render_widget(Clear, area);
let (title, body) = modal_content(modal);
let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
fn modal_content(modal: &Modal) -> (&'static str, String) {
match modal {
Modal::Help => (
"Help",
"Keys:\n 1-5 jump to tab\n Tab next tab\n : command palette\n\
\n ? this help\n q/Esc close modal\n C-v toggle emacs/vim mode"
.to_string(),
),
Modal::ConfigSet(m) => {
let focus_marker = |field| {
if m.focus == field { "â–¸" } else { " " }
let body = format!(
"{} name: [{}]\n{} value: [{}]\n\nEnter to save, Esc to cancel.",
focus_marker(modal::ConfigSetField::Name),
m.name.buffer(),
focus_marker(modal::ConfigSetField::Value),
m.value.buffer(),
("Set config", body)
#[must_use]
pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
.split(r);
Layout::default()
.direction(Direction::Horizontal)
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
.split(popup_layout[1])[1]
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::EditMode;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use sqlx::types::Uuid;
#[test]
fn centered_rect_clamps_to_parent() {
let parent = Rect::new(0, 0, 100, 100);
let r = centered_rect(60, 30, parent);
assert!(r.x + r.width <= parent.x + parent.width);
assert!(r.y + r.height <= parent.y + parent.height);
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.map(|cell| cell.symbol())
.collect()
fn render_console(app: &App) -> String {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
.draw(|frame| draw(frame, app))
.expect("draw must not panic");
buffer_text(&terminal)
fn console_app() -> App {
let mut app = App::new(Uuid::nil(), EditMode::Emacs);
app.active_tab = Tab::Console;
app
fn console_tab_renders_scrollback_and_prompt() {
let mut app = console_app();
app.console.push_scrollback("(:id 0 :value 42)");
app.console.input.insert_char('(');
let text = render_console(&app);
assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
assert!(text.contains("nms>"), "prompt missing");
fn console_prompt_shows_continuation_marker_when_pending() {
for c in "(list".chars() {
app.console.input.insert_char(c);
app.console.take_complete_form();
assert!(!app.console.pending.is_empty(), "form must be pending");
assert!(text.contains("...>"), "continuation marker missing");
assert!(!text.contains("nms>"), "primary prompt should be hidden");
fn console_hint_switches_with_focus() {
let unfocused = render_console(&app);
assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
app.console_input_active = true;
let focused = render_console(&app);
assert!(focused.contains("Esc blur"), "focused hint missing");
assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
fn console_prompt_style_bold_only_when_focused() {
let app = console_app();
.draw(|frame| draw(frame, &app))
let prompt_cell = prompt_marker_cell(&terminal);
assert!(
!prompt_cell.modifier.contains(Modifier::BOLD),
"blurred prompt must not be bold"
let mut focused = console_app();
focused.console_input_active = true;
.draw(|frame| draw(frame, &focused))
prompt_cell.modifier.contains(Modifier::BOLD),
"focused prompt must be bold"
fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
let buffer = terminal.backend().buffer();
for y in 0..buffer.area.height {
if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
return buffer[(1, y)].clone();
panic!("nms> prompt marker not found in rendered buffer");
fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
for i in 0..100 {
app.console.push_scrollback(format!("line-{i:03}"));
text.contains(&format!("line-{:03}", 99)),
"newest line must be visible"
!text.contains("line-000"),
"oldest line must be scrolled out of the viewport"