Files
Cagire/src/views/render.rs
2026-01-28 13:54:29 +01:00

849 lines
31 KiB
Rust

use std::time::Instant;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
use ratatui::Frame;
use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::SourceSpan;
use crate::page::Page;
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
use crate::widgets::{
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
};
use super::{
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
};
fn adjust_spans_for_line(
spans: &[SourceSpan],
line_start: usize,
line_len: usize,
) -> Vec<SourceSpan> {
spans
.iter()
.filter_map(|s| {
if s.end <= line_start || s.start >= line_start + line_len {
return None;
}
Some(SourceSpan {
start: s.start.max(line_start) - line_start,
end: (s.end.min(line_start + line_len)) - line_start,
})
})
.collect()
}
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
let term = frame.area();
let blank = " ".repeat(term.width as usize);
let lines: Vec<Line> = (0..term.height).map(|_| Line::raw(&blank)).collect();
frame.render_widget(Paragraph::new(lines), term);
if app.ui.show_title {
title_view::render(frame, term, &app.ui);
return;
}
let padded = Rect {
x: term.x + 4,
y: term.y + 1,
width: term.width.saturating_sub(8),
height: term.height.saturating_sub(2),
};
let [header_area, _padding, body_area, _bottom_padding, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(3),
])
.areas(padded);
render_header(frame, app, link, snapshot, header_area);
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
if body_area.width >= 120 {
let panel_width = body_area.width * 35 / 100;
let [main, side] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.areas(body_area);
(main, Some(side))
} else {
let panel_height = body_area.height * 40 / 100;
let [main, side] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
.areas(body_area);
(main, Some(side))
}
} else {
(body_area, None)
};
match app.page {
Page::Main => main_view::render(frame, app, snapshot, page_area),
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
Page::Engine => engine_view::render(frame, app, page_area),
Page::Options => options_view::render(frame, app, link, page_area),
Page::Help => help_view::render(frame, app, page_area),
Page::Dict => dict_view::render(frame, app, page_area),
}
if let Some(side_area) = panel_area {
render_side_panel(frame, app, side_area);
}
render_footer(frame, app, footer_area);
render_modal(frame, app, snapshot, term);
let show_minimap = app
.ui
.minimap_until
.map(|until| Instant::now() < until)
.unwrap_or(false);
if show_minimap {
let tiles: Vec<NavTile> = Page::ALL
.iter()
.map(|p| {
let (col, row) = p.grid_pos();
NavTile {
col,
row,
name: p.name(),
}
})
.collect();
let selected = app.page.grid_pos();
NavMinimap::new(&tiles, selected).render_centered(frame, term);
}
}
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
let focused = app.panel.focus == PanelFocus::Side;
match &app.panel.side {
Some(SidePanel::SampleBrowser(state)) => {
let entries = state.entries();
SampleBrowser::new(&entries, state.cursor)
.scroll_offset(state.scroll_offset)
.search(&state.search_query, state.search_active)
.focused(focused)
.render(frame, area);
}
None => {}
}
}
fn render_header(
frame: &mut Frame,
app: &App,
link: &LinkState,
snapshot: &SequencerSnapshot,
area: Rect,
) {
use crate::model::PatternSpeed;
let bank = &app.project_state.project.banks[app.editor_ctx.bank];
let pattern = &bank.patterns[app.editor_ctx.pattern];
// Layout: [Transport] [Live] [Tempo] [Bank] [Pattern] [Stats]
let [transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] =
Layout::horizontal([
Constraint::Min(12),
Constraint::Length(9),
Constraint::Min(14),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
])
.areas(area);
// Transport block
let (transport_bg, transport_text) = if app.playback.playing {
(Color::Rgb(30, 80, 30), " ▶ PLAYING ")
} else {
(Color::Rgb(80, 30, 30), " ■ STOPPED ")
};
let transport_style = Style::new().bg(transport_bg).fg(Color::White);
frame.render_widget(
Paragraph::new(transport_text)
.style(transport_style)
.alignment(Alignment::Center),
transport_area,
);
// Fill indicator
let fill = app.live_keys.fill();
let fill_style = if fill {
Style::new()
.bg(Color::Rgb(30, 30, 35))
.fg(Color::Rgb(100, 220, 100))
} else {
Style::new()
.bg(Color::Rgb(30, 30, 35))
.fg(Color::Rgb(60, 60, 70))
};
frame.render_widget(
Paragraph::new(if fill { "F" } else { "·" })
.style(fill_style)
.alignment(Alignment::Center),
live_area,
);
// Tempo block
let tempo_style = Style::new()
.bg(Color::Rgb(60, 30, 60))
.fg(Color::White)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(format!(" {:.1} BPM ", link.tempo()))
.style(tempo_style)
.alignment(Alignment::Center),
tempo_area,
);
// Bank block
let bank_name = bank
.name
.as_deref()
.map(|n| format!(" {n} "))
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White);
frame.render_widget(
Paragraph::new(bank_name)
.style(bank_style)
.alignment(Alignment::Center),
bank_area,
);
// Pattern block (name + length + speed + page + iter)
let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
let speed_info = if pattern.speed != PatternSpeed::NORMAL {
format!(" · {}", pattern.speed.label())
} else {
String::new()
};
let total_pages = pattern.length.div_ceil(32);
let page_info = if total_pages > 1 {
let current_page = app.editor_ctx.step / 32 + 1;
format!(" · {current_page}/{total_pages}")
} else {
String::new()
};
let iter_info = snapshot
.get_iter(app.editor_ctx.bank, app.editor_ctx.pattern)
.map(|iter| format!(" · #{}", iter + 1))
.unwrap_or_default();
let pattern_text = format!(
" {} · {} steps{}{}{} ",
pattern_name, pattern.length, speed_info, page_info, iter_info
);
let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White);
frame.render_widget(
Paragraph::new(pattern_text)
.style(pattern_style)
.alignment(Alignment::Center),
pattern_area,
);
// Stats block
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let peers = link.peers();
let voices = app.metrics.active_voices;
let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} ");
let stats_style = Style::new()
.bg(Color::Rgb(35, 35, 40))
.fg(Color::Rgb(150, 150, 160));
frame.render_widget(
Paragraph::new(stats_text)
.style(stats_style)
.alignment(Alignment::Right),
stats_area,
);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
let available_width = inner.width as usize;
let page_indicator = match app.page {
Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS]",
Page::Engine => "[ENGINE]",
Page::Options => "[OPTIONS]",
Page::Help => "[HELP]",
Page::Dict => "[DICT]",
};
let content = if let Some(ref msg) = app.ui.status_message {
Line::from(vec![
Span::styled(
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" "),
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
])
} else {
let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => vec![
("Space", "Play"),
("Enter", "Edit"),
("t", "Toggle"),
("Tab", "Samples"),
("?", "Keys"),
],
Page::Patterns => vec![
("←→↑↓", "Navigate"),
("Enter", "Select"),
("Space", "Play"),
("Esc", "Back"),
("r", "Rename"),
("Del", "Reset"),
("?", "Keys"),
],
Page::Engine => vec![
("Tab", "Section"),
("←→", "Switch/Adjust"),
("↑↓", "Navigate"),
("Enter", "Select"),
("A", "Add path"),
("?", "Keys"),
],
Page::Options => vec![
("Tab", "Next"),
("←→", "Toggle"),
("Space", "Play"),
("?", "Keys"),
],
Page::Help => vec![
("↑↓", "Scroll"),
("Tab", "Topic"),
("PgUp/Dn", "Page"),
("/", "Search"),
("?", "Keys"),
],
Page::Dict => vec![
("Tab", "Focus"),
("↑↓", "Navigate"),
("/", "Search"),
("?", "Keys"),
],
};
let page_width = page_indicator.chars().count();
let bindings_content_width: usize = bindings
.iter()
.map(|(k, a)| k.chars().count() + 1 + a.chars().count())
.sum();
let n = bindings.len();
let total_content = page_width + bindings_content_width;
let total_gaps = available_width.saturating_sub(total_content);
let gap_count = n + 1;
let base_gap = total_gaps / gap_count;
let extra = total_gaps % gap_count;
let mut spans = vec![
Span::styled(
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })),
];
for (i, (key, action)) in bindings.into_iter().enumerate() {
spans.push(Span::styled(
key.to_string(),
Style::new().fg(Color::Yellow),
));
spans.push(Span::styled(
format!(":{action}"),
Style::new().fg(Color::Rgb(120, 125, 135)),
));
if i < n - 1 {
let gap = base_gap + if i + 1 < extra { 1 } else { 0 };
spans.push(Span::raw(" ".repeat(gap)));
}
}
Line::from(spans)
};
let footer = Paragraph::new(content).block(block);
frame.render_widget(footer, area);
}
fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) {
match &app.ui.modal {
Modal::None => {}
Modal::ConfirmQuit { selected } => {
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
}
Modal::ConfirmDeleteStep { step, selected, .. } => {
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
.render_centered(frame, term);
}
Modal::ConfirmDeleteSteps { steps, selected, .. } => {
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
let label = format!("Delete steps {}?", nums.join(", "));
ConfirmModal::new("Confirm", &label, *selected)
.render_centered(frame, term);
}
Modal::ConfirmResetPattern {
pattern, selected, ..
} => {
ConfirmModal::new(
"Confirm",
&format!("Reset pattern {}?", pattern + 1),
*selected,
)
.render_centered(frame, term);
}
Modal::ConfirmResetBank { bank, selected } => {
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
.render_centered(frame, term);
}
Modal::FileBrowser(state) => {
use crate::state::file_browser::FileBrowserMode;
use crate::widgets::FileBrowserModal;
let (title, border_color) = match state.mode {
FileBrowserMode::Save => ("Save As", Color::Green),
FileBrowserMode::Load => ("Load From", Color::Blue),
};
let entries: Vec<(String, bool)> = state
.entries
.iter()
.map(|e| (e.name.clone(), e.is_dir))
.collect();
FileBrowserModal::new(title, &state.input, &entries)
.selected(state.selected)
.scroll_offset(state.scroll_offset)
.border_color(border_color)
.width(60)
.height(18)
.render_centered(frame, term);
}
Modal::RenameBank { bank, name } => {
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
.width(40)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::RenamePattern {
bank,
pattern,
name,
} => {
TextInputModal::new(
&format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
name,
)
.width(40)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::SetPattern { field, input } => {
let (title, hint) = match field {
PatternField::Length => ("Set Length (1-128)", "Enter number"),
PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
};
TextInputModal::new(title, input)
.hint(hint)
.width(45)
.border_color(Color::Yellow)
.render_centered(frame, term);
}
Modal::SetTempo(input) => {
TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::AddSamplePath(state) => {
use crate::widgets::FileBrowserModal;
let entries: Vec<(String, bool)> = state
.entries
.iter()
.map(|e| (e.name.clone(), e.is_dir))
.collect();
FileBrowserModal::new("Add Sample Path", &state.input, &entries)
.selected(state.selected)
.scroll_offset(state.scroll_offset)
.border_color(Color::Magenta)
.width(60)
.height(18)
.render_centered(frame, term);
}
Modal::Preview => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 80 / 100).max(10);
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let source_idx = step.and_then(|s| s.source);
let title = if let Some(src) = source_idx {
format!("Step {:02}{:02}", step_idx + 1, src + 1)
} else {
format!("Step {:02}", step_idx + 1)
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(Color::Rgb(120, 125, 135))
.render_centered(frame, term);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new("(empty)")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(80, 85, 95)));
let centered_area = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(empty, centered_area);
} else {
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = pattern.resolve_source(step_idx);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(
&t.executed_spans,
line_start,
line_str.len(),
);
let sel = adjust_spans_for_line(
&t.selected_spans,
line_start,
line_str.len(),
);
highlight_line_with_runtime(line_str, &exec, &sel)
} else {
highlight_line(line_str)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
}
Modal::Editor => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let step_num = app.editor_ctx.step + 1;
let flash_kind = app.ui.flash_kind();
let border_color = match flash_kind {
Some(FlashKind::Error) => Color::Red,
Some(FlashKind::Info) => Color::White,
Some(FlashKind::Success) => Color::Green,
None => Color::Rgb(100, 160, 180),
};
let inner = ModalFrame::new(&format!("Step {step_num:02} Script"))
.width(width)
.height(height)
.border_color(border_color)
.render_centered(frame, term);
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = app
.current_edit_pattern()
.resolve_source(app.editor_ctx.step);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let text_lines = app.editor_ctx.editor.lines();
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
let mut offset = 0;
for line in text_lines.iter() {
line_offsets.push(offset);
offset += line.len() + 1;
}
let highlighter = |row: usize, line: &str| -> Vec<(Style, String)> {
let line_start = line_offsets[row];
let (exec, sel) = match trace {
Some(t) => (
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
),
None => (Vec::new(), Vec::new()),
};
highlight::highlight_line_with_runtime(line, &exec, &sel)
};
let show_search = app.editor_ctx.editor.search_active()
|| !app.editor_ctx.editor.search_query().is_empty();
let (search_area, editor_area, hint_area) = if show_search {
let search_area = Rect::new(inner.x, inner.y, inner.width, 1);
let editor_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(2),
);
let hint_area =
Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
(Some(search_area), editor_area, hint_area)
} else {
let editor_area = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(1),
);
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
(None, editor_area, hint_area)
};
if let Some(sa) = search_area {
let style = if app.editor_ctx.editor.search_active() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let cursor = if app.editor_ctx.editor.search_active() {
"_"
} else {
""
};
let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor);
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa);
}
if let Some(kind) = flash_kind {
let bg = match kind {
FlashKind::Error => Color::Rgb(60, 10, 10),
FlashKind::Info => Color::Rgb(30, 30, 40),
FlashKind::Success => Color::Rgb(10, 30, 10),
};
let flash_block = Block::default().style(Style::default().bg(bg));
frame.render_widget(flash_block, editor_area);
}
app.editor_ctx
.editor
.render(frame, editor_area, &highlighter);
let dim = Style::default().fg(Color::DarkGray);
let key = Style::default().fg(Color::Yellow);
let hint = if app.editor_ctx.editor.search_active() {
Line::from(vec![
Span::styled("Enter", key),
Span::styled(" confirm ", dim),
Span::styled("Esc", key),
Span::styled(" cancel", dim),
])
} else {
Line::from(vec![
Span::styled("Esc", key),
Span::styled(" save ", dim),
Span::styled("C-e", key),
Span::styled(" eval ", dim),
Span::styled("C-f", key),
Span::styled(" find ", dim),
Span::styled("C-n", key),
Span::styled("/", dim),
Span::styled("C-p", key),
Span::styled(" next/prev ", dim),
Span::styled("C-u", key),
Span::styled("/", dim),
Span::styled("C-r", key),
Span::styled(" undo/redo", dim),
])
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
}
Modal::PatternProps {
bank,
pattern,
field,
name,
length,
speed,
quantization,
sync_mode,
} => {
use crate::state::PatternPropsField;
let width = 50u16;
let height = 12u16;
let x = (term.width.saturating_sub(width)) / 2;
let y = (term.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
let block = Block::bordered()
.title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let fields = [
("Name", name.as_str(), *field == PatternPropsField::Name),
(
"Length",
length.as_str(),
*field == PatternPropsField::Length,
),
("Speed", &speed.label(), *field == PatternPropsField::Speed),
(
"Quantization",
quantization.label(),
*field == PatternPropsField::Quantization,
),
(
"Sync Mode",
sync_mode.label(),
*field == PatternPropsField::SyncMode,
),
];
for (i, (label, value, selected)) in fields.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let (label_style, value_style) = if *selected {
(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Style::default().fg(Color::White).bg(Color::DarkGray),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::White),
)
};
let label_area = Rect::new(inner.x + 1, y, 14, 1);
let value_area = Rect::new(inner.x + 16, y, inner.width.saturating_sub(18), 1);
frame.render_widget(
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
}
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hint = Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Yellow)),
Span::styled(" nav ", Style::default().fg(Color::DarkGray)),
Span::styled("←→", Style::default().fg(Color::Yellow)),
Span::styled(" change ", Style::default().fg(Color::DarkGray)),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::styled(" save ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(hint), hint_area);
}
Modal::KeybindingsHelp { scroll } => {
let width = (term.width * 80 / 100).clamp(60, 100);
let height = (term.height * 80 / 100).max(15);
let title = format!("Keybindings — {}", app.page.name());
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(Color::Rgb(100, 160, 180))
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings
.iter()
.enumerate()
.skip(*scroll)
.take(visible_rows)
.map(|(i, (key, name, desc))| {
let bg = if i % 2 == 0 {
Color::Rgb(25, 25, 30)
} else {
Color::Rgb(35, 35, 42)
};
Row::new(vec![
Cell::from(*key).style(Style::default().fg(Color::Yellow)),
Cell::from(*name).style(Style::default().fg(Color::Cyan)),
Cell::from(*desc).style(Style::default().fg(Color::White)),
])
.style(Style::default().bg(bg))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Length(12),
Constraint::Fill(1),
],
)
.column_spacing(2);
let table_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
};
frame.render_widget(table, table_area);
let hint_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let hint = Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Yellow)),
Span::styled(" scroll ", Style::default().fg(Color::DarkGray)),
Span::styled("PgUp/Dn", Style::default().fg(Color::Yellow)),
Span::styled(" page ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc/?", Style::default().fg(Color::Yellow)),
Span::styled(" close", Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
}
}
}